diff --git a/Dockerfile b/Dockerfile
index b522c8f9..5460af81 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.10
+FROM python:3.11
ARG TAPIR_VERSION
ENV TAPIR_VERSION=$TAPIR_VERSION
ENV PYTHONUNBUFFERED=1
diff --git a/docker-compose.yml b/docker-compose.yml
index c61c80d5..75308442 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,33 +1,16 @@
version: "3.9"
services:
- keycloak-server:
+ keycloak:
build:
context: ./docker/keycloak
dockerfile: Dockerfile
-
ports:
- "8080:8080"
-
volumes:
- ./docker/keycloak/import:/opt/keycloak/data/import
-
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
-
- openldap:
- image: "osixia/openldap"
- environment:
- LDAP_ORGANISATION: "WirGarten Lüneburg"
- LDAP_DOMAIN: "lueneburg.wirgarten.com"
- LDAP_ADMIN_PASSWORD: "admin"
- LDAP_READONLY_USER: "true"
- ports:
- - "389:389"
- volumes:
- - ./ldap_testdata.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/50-testdata.ldif
- # Required so that the container doesn't modify the testdata ldif
- command: --copy-service
web:
build: .
@@ -40,10 +23,9 @@ services:
VIRTUAL_HOST: localhost
DEBUG: 1
depends_on:
- - openldap
- db
- selenium
- - keycloak-server
+ - keycloak
nginx-proxy:
image: jwilder/nginx-proxy
diff --git a/docker/keycloak/Dockerfile b/docker/keycloak/Dockerfile
index 4f10e208..005ab626 100644
--- a/docker/keycloak/Dockerfile
+++ b/docker/keycloak/Dockerfile
@@ -1,11 +1,10 @@
-FROM quay.io/keycloak/keycloak:20.0.1
+FROM quay.io/keycloak/keycloak:20.0.3
COPY ./import/*.json /opt/keycloak/data/import/
-#RUN /opt/keycloak/bin/kc.sh import --dir /opt/keycloak/data/import
+RUN /opt/keycloak/bin/kc.sh import --dir /opt/keycloak/data/import
ENV ROOT_LOGLEVEL=ALL
-
-ENV KEYCLOAK_LOGLEVEL=DEBUG
+ENV KEYCLOAK_LOGLEVEL=ALL
CMD ["start-dev"]
\ No newline at end of file
diff --git a/docker/keycloak/import/master-realm.json b/docker/keycloak/import/master-realm.json
deleted file mode 100644
index bbf10e6d..00000000
--- a/docker/keycloak/import/master-realm.json
+++ /dev/null
@@ -1,1863 +0,0 @@
-{
- "id" : "d2b4d76c-26b9-42f8-8481-793fa013bfb5",
- "realm" : "master",
- "displayName" : "Keycloak",
- "displayNameHtml" : "
"]
[tool.poetry.dependencies]
-python = "^3.10"
+python = "^3.11"
Django = "~3.2.12"
-django-ldapdb = "^1.5.1"
django-weasyprint = "^1.1.0.post2"
django-extensions = "3.1.5"
-django-bootstrap-datepicker-plus = "^3.0.6"
+django-bootstrap-datepicker-plus = "^5.0.2"
psycopg2-binary = "^2.9.3"
django-tables2 = "^2.4.1"
django-filter = "^2.4.0"
@@ -33,9 +32,10 @@ cleo = "^0.8.1"
tomlkit = "^0.10.1"
nanoid = "^2.0"
lxml= "^4.9.2"
-python-keycloak = "^2.6.0"
+python-keycloak = "^2.9.0"
webtest = "^3.0.0"
django-webtest = "^1.9.10"
+PyJWT = "2.6.0"
[tool.poetry.dev-dependencies]
ipython = "^8.1.0"
diff --git a/tapir/accounts/backends.py b/tapir/accounts/backends.py
deleted file mode 100644
index ff2befc8..00000000
--- a/tapir/accounts/backends.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from django.contrib.auth import get_user_model
-from django.contrib.auth.backends import BaseBackend
-from django.conf import settings
-from keycloak import KeycloakOpenID, KeycloakAuthenticationError, KeycloakPostError
-from tapir.accounts.models import KeycloakUser
-
-
-User = get_user_model()
-class KeycloakAuthorizationCredentialsBackend(BaseBackend):
-
- def get_user(self, user_id):
-
- try:
- user = User.objects.get(pk=user_id)
- except User.DoesNotExist:
- return None
-
- # needs to validate token before returning user
- return user
-
- def authenticate(self, request, username=None, password=None):
- config = settings.KEYCLOAK_CONFIG
- kk = KeycloakOpenID(
- server_url=config["SERVER_URL"],
- client_id=config["CLIENT_ID"],
- realm_name=config["REALM_NAME"],
- client_secret_key=config["CLIENT_SECRET_KEY"],
- )
- try:
- token = kk.token(username, password)
- except KeycloakAuthenticationError:
- return None
- except KeycloakPostError as e:
- return None
-
- remote_user = kk.introspect(token["access_token"])
- user, _ = User.objects.get_or_create(
- username=remote_user['sub'],
- defaults={
- 'first_name': remote_user.get('given_name', ''),
- 'last_name': remote_user.get('family_name', '')
- }
- )
- if not remote_user["active"]:
- if user.is_active:
- user.is_active = False
- user.save()
- return None
- keycloakuser, _ = KeycloakUser.objects.get_or_create(
- user=user,
- sub=remote_user["sub"],
- defaults={},
- )
- return keycloakuser.user
-
\ No newline at end of file
diff --git a/tapir/accounts/fixtures/admin_account.json b/tapir/accounts/fixtures/admin_account.json
deleted file mode 100644
index 9f3d5471..00000000
--- a/tapir/accounts/fixtures/admin_account.json
+++ /dev/null
@@ -1,28 +0,0 @@
-[
- {
- "model": "accounts.tapiruser",
- "pk": 1000,
- "fields": {
- "password": "!MaAS6mbBl1AtqVYmB0J0835NTpZLyb23JUGV2dAR",
- "last_login": "2021-03-23T12:40:54.480Z",
- "is_superuser": true,
- "username": "admin",
- "first_name": "",
- "last_name": "",
- "email": "admin@example.com",
- "is_staff": true,
- "is_active": true,
- "date_joined": "2021-03-23T12:40:40.989Z",
- "phone_number": "",
- "birthdate": null,
- "street": "",
- "street_2": "",
- "postcode": "",
- "city": "",
- "country": "DE",
- "preferred_language": "en",
- "groups": [],
- "user_permissions": []
- }
- }
-]
\ No newline at end of file
diff --git a/tapir/accounts/forms.py b/tapir/accounts/forms.py
deleted file mode 100644
index a8a0650d..00000000
--- a/tapir/accounts/forms.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from django import forms
-from django.contrib.auth import forms as auth_forms
-from django.forms import TextInput
-
-from tapir.accounts.models import TapirUser
-from tapir.utils.forms import DateInput, TapirPhoneNumberField
-
-
-class TapirUserForm(forms.ModelForm):
- phone_number = TapirPhoneNumberField(required=False)
-
- class Meta:
- model = TapirUser
- fields = [
- "first_name",
- "last_name",
- "username",
- "phone_number",
- "email",
- "birthdate",
- "street",
- "street_2",
- "postcode",
- "city",
- "preferred_language",
- ]
- widgets = {
- "birthdate": DateInput(),
- "username": TextInput(attrs={"readonly": True}),
- }
-
-
-class PasswordResetForm(auth_forms.PasswordResetForm):
- def get_users(self, email):
- """Given an email, return matching user(s) who should receive a reset.
- This allows subclasses to more easily customize the default policies
- that prevent inactive users and users with unusable passwords from
- resetting their password.
- """
- email_field_name = auth_forms.UserModel.get_email_field_name()
- active_users = auth_forms.UserModel._default_manager.filter(
- **{
- "%s__iexact" % email_field_name: email,
- "is_active": True,
- }
- )
- return (
- u
- for u in active_users
- # Users with unusable passwords in the DB should be able to reset their passwords, the new password will be
- # set in the LDAP instead. See models.LdapUser
- # if u.has_usable_password() and
- if auth_forms._unicode_ci_compare(email, getattr(u, email_field_name))
- )
diff --git a/tapir/accounts/management/commands/create_admin.py b/tapir/accounts/management/commands/create_admin.py
new file mode 100644
index 00000000..e627e8cd
--- /dev/null
+++ b/tapir/accounts/management/commands/create_admin.py
@@ -0,0 +1,48 @@
+import sys
+
+from django.core.management import BaseCommand
+
+from tapir.accounts.models import TapirUser
+
+
+class Command(BaseCommand):
+ help = "Create the initial admin account"
+
+ def handle(self, *args, **options):
+ if TapirUser.objects.filter(is_superuser=True).exists():
+ sys.stderr.write(
+ "There is already an admin account in the system, this command is disabled.\n"
+ )
+ return
+
+ admin = TapirUser(
+ first_name=options["first_name"],
+ last_name=options["last_name"],
+ email=options["email"],
+ is_staff=True,
+ is_superuser=True,
+ )
+ admin.save(initial_password=options["password"])
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--first-name",
+ help="First name",
+ type=str,
+ required=True,
+ )
+ parser.add_argument(
+ "--last-name",
+ help="Last name",
+ type=str,
+ required=True,
+ )
+ parser.add_argument(
+ "--email",
+ help="Email address",
+ type=str,
+ required=True,
+ )
+ parser.add_argument(
+ "--password", help="Initial Password", type=str, required=True
+ )
diff --git a/tapir/accounts/middleware.py b/tapir/accounts/middleware.py
index 8f60ad62..626c62d5 100644
--- a/tapir/accounts/middleware.py
+++ b/tapir/accounts/middleware.py
@@ -1,22 +1,81 @@
-import datetime
+import logging
+import time
from django.conf import settings
-from django.utils import timezone
from django.utils.deprecation import MiddlewareMixin
+from jwt import decode
+from keycloak import KeycloakOpenID
+from tapir.accounts.models import TapirUser
-class ClientPermsMiddleware(MiddlewareMixin):
- def process_request(self, request):
- if request.user.is_anonymous:
- return
+logger = logging.getLogger(__name__)
- request_comes_from_welcome_desk = (
- request.META.get("HTTP_X_SSL_CLIENT_VERIFY") == "SUCCESS"
- and request.META["HTTP_X_SSL_CLIENT_S_DN"]
- in settings.CLIENT_PERMISSIONS.keys()
+
+class KeycloakMiddleware(MiddlewareMixin):
+ """KeyCloak Middleware for authentication and authorization."""
+
+ def __init__(self, get_response):
+ """One-time initialization of middleware."""
+ self.get_response = get_response # Required by django
+ try:
+ self.setup_keycloak()
+ except Exception:
+ raise Exception(
+ f"{__name__}: Failed to set up keycloak connection."
+ "Please check settings.KEYCLOAK."
+ )
+
+ def setup_keycloak(self):
+ """Set up KeyCloakOpenID with given settings."""
+ self.config = settings.KEYCLOAK_ADMIN_CONFIG
+ self.keycloak = KeycloakOpenID(
+ server_url=self.config["PUBLIC_URL"],
+ realm_name=self.config["REALM_NAME"],
+ client_id=self.config["FRONTEND_CLIENT_ID"],
)
- if request_comes_from_welcome_desk:
- request.user.client_perms = settings.CLIENT_PERMISSIONS[
- request.META["HTTP_X_SSL_CLIENT_S_DN"]
- ]
- return
+
+ def __call__(self, request):
+ """Handle default requests."""
+ return self.get_response(request) # Required by django
+
+ def auth_failed(self, log_message, error):
+ """Return authentication failed message in log and API."""
+ logger.debug(f"{log_message}: {repr(error)}")
+
+ def process_view(self, request, view_func, view_args, view_kwargs):
+ """Check for authentication and try to get user from keycloak."""
+ # Return unauthenticated request if no authorization is found
+ if "token" not in request.COOKIES:
+ logger.debug(f"No authorization found. Using public user.")
+ return None
+
+ # Retrieve token and user or return failure message
+ access_token = request.COOKIES.get("token")
+
+ # Decode token
+ try:
+ data = decode(access_token, options={"verify_signature": False})
+ if data["exp"] < int(time.time()):
+ return self.auth_failed("Token expired on: ", data["exp"])
+ except Exception as e:
+ return self.auth_failed("Could not decode token", e)
+
+ # Add user to request
+ keycloak_id = data.get("sub", None)
+ if keycloak_id is None:
+ return self.auth_failed("Could not get id from token: ", data)
+ try:
+ request.user = user = TapirUser.objects.get(keycloak_id=keycloak_id)
+ user.email_verified = data.get("email_verified", False)
+
+ roles = data.get("realm_access", {}).get("roles", [])
+
+ user.roles = []
+ for role in roles:
+ if role not in settings.KEYCLOAK_NON_TAPIR_ROLES:
+ user.roles.append(role)
+ except Exception as e:
+ return self.auth_failed("Could not find matching TapirUser", e)
+
+ # Return authenticated request if no exception is thrown
+ return None
diff --git a/tapir/accounts/migrations/0001_initial.py b/tapir/accounts/migrations/0001_initial.py
index 1ae0dff8..04b2c624 100644
--- a/tapir/accounts/migrations/0001_initial.py
+++ b/tapir/accounts/migrations/0001_initial.py
@@ -1,32 +1,27 @@
-# Generated by Django 3.2.15 on 2022-09-21 11:04
+# Generated by Django 3.2.16 on 2023-01-30 18:56
+from django.contrib.postgres.operations import HStoreExtension
+import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
-import ldapdb.models.fields
+import functools
import phonenumber_field.modelfields
import tapir.accounts.models
-import tapir.accounts.validators
+import tapir.core.models
import tapir.utils.models
class Migration(migrations.Migration):
+
initial = True
dependencies = []
operations = [
+ HStoreExtension(),
migrations.CreateModel(
name="TapirUser",
fields=[
- (
- "id",
- models.AutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
@@ -43,21 +38,18 @@ class Migration(migrations.Migration):
),
),
(
- "first_name",
- models.CharField(
- blank=True, max_length=150, verbose_name="first name"
- ),
- ),
- (
- "last_name",
+ "username",
models.CharField(
- blank=True, max_length=150, verbose_name="last name"
- ),
- ),
- (
- "email",
- models.EmailField(
- blank=True, max_length=254, verbose_name="email address"
+ error_messages={
+ "unique": "A user with that username already exists."
+ },
+ help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
+ max_length=150,
+ unique=True,
+ validators=[
+ django.contrib.auth.validators.UnicodeUsernameValidator()
+ ],
+ verbose_name="username",
),
),
(
@@ -83,18 +75,31 @@ class Migration(migrations.Migration):
),
),
(
- "username",
+ "id",
models.CharField(
- error_messages={
- "unique": "A user with that username already exists."
- },
- help_text="Required. 150 characters or fewer. Letters, digits and ./-/_ only.",
- max_length=150,
+ default=functools.partial(
+ tapir.core.models.generate_id, *(), **{}
+ ),
+ max_length=10,
+ primary_key=True,
+ serialize=False,
unique=True,
- validators=[tapir.accounts.validators.UsernameValidator()],
- verbose_name="Username",
+ verbose_name="ID",
),
),
+ (
+ "keycloak_id",
+ models.CharField(max_length=64, null=True, unique=True),
+ ),
+ (
+ "first_name",
+ models.CharField(max_length=150, verbose_name="First Name"),
+ ),
+ (
+ "last_name",
+ models.CharField(max_length=150, verbose_name="Last Name"),
+ ),
+ ("email", models.CharField(max_length=150, verbose_name="Email")),
(
"phone_number",
phonenumber_field.modelfields.PhoneNumberField(
@@ -395,63 +400,5 @@ class Migration(migrations.Migration):
options={
"abstract": False,
},
- managers=[
- ("objects", tapir.accounts.models.TapirUserManager()),
- ],
- ),
- migrations.CreateModel(
- name="LdapGroup",
- fields=[
- (
- "dn",
- ldapdb.models.fields.CharField(
- max_length=200, primary_key=True, serialize=False
- ),
- ),
- (
- "cn",
- ldapdb.models.fields.CharField(
- db_column="cn", max_length=200, serialize=False
- ),
- ),
- (
- "description",
- ldapdb.models.fields.CharField(
- db_column="description", max_length=200
- ),
- ),
- ("members", ldapdb.models.fields.ListField(db_column="member")),
- ],
- options={
- "verbose_name": "LDAP group",
- "verbose_name_plural": "LDAP groups",
- },
- ),
- migrations.CreateModel(
- name="LdapPerson",
- fields=[
- (
- "dn",
- ldapdb.models.fields.CharField(
- max_length=200, primary_key=True, serialize=False
- ),
- ),
- (
- "uid",
- ldapdb.models.fields.CharField(
- db_column="uid", max_length=200, serialize=False
- ),
- ),
- ("cn", ldapdb.models.fields.CharField(db_column="cn", max_length=200)),
- ("sn", ldapdb.models.fields.CharField(db_column="sn", max_length=200)),
- (
- "mail",
- ldapdb.models.fields.CharField(db_column="mail", max_length=200),
- ),
- ],
- options={
- "verbose_name": "LDAP person",
- "verbose_name_plural": "LDAP people",
- },
),
]
diff --git a/tapir/accounts/migrations/0002_initial.py b/tapir/accounts/migrations/0002_initial.py
index e0114dff..111711bb 100644
--- a/tapir/accounts/migrations/0002_initial.py
+++ b/tapir/accounts/migrations/0002_initial.py
@@ -1,22 +1,21 @@
-# Generated by Django 3.2.15 on 2022-09-21 11:04
+# Generated by Django 3.2.16 on 2023-01-30 18:56
import django.contrib.postgres.fields.hstore
-from django.contrib.postgres.operations import HStoreExtension
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
+
initial = True
dependencies = [
- ("auth", "0012_alter_user_first_name_max_length"),
- ("accounts", "0001_initial"),
("log", "0001_initial"),
+ ("accounts", "0001_initial"),
+ ("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
- HStoreExtension(),
migrations.CreateModel(
name="UpdateTapirUserLogEntry",
fields=[
diff --git a/tapir/accounts/migrations/0003_auto_20221117_1841.py b/tapir/accounts/migrations/0003_auto_20221117_1841.py
deleted file mode 100644
index 02e62755..00000000
--- a/tapir/accounts/migrations/0003_auto_20221117_1841.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# Generated by Django 3.2.16 on 2022-11-17 17:41
-
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-import ldapdb.models.fields
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('accounts', '0002_initial'),
- ]
-
- operations = [
- migrations.CreateModel(
- name='KeycloakUser',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('sub', models.CharField(max_length=255, unique=True)),
- ('realm', models.CharField(max_length=255)),
- ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='keycloak_user', to=settings.AUTH_USER_MODEL)),
- ],
- ),
- migrations.AddConstraint(
- model_name='keycloakuser',
- constraint=models.UniqueConstraint(fields=('sub', 'realm'), name='unique_sub_realm'),
- ),
- ]
diff --git a/tapir/wirgarten/migrations/0036_waitinglistentry.py b/tapir/accounts/migrations/0003_emailchangerequest.py
similarity index 60%
rename from tapir/wirgarten/migrations/0036_waitinglistentry.py
rename to tapir/accounts/migrations/0003_emailchangerequest.py
index 1c9b6235..f6b7e6a8 100644
--- a/tapir/wirgarten/migrations/0036_waitinglistentry.py
+++ b/tapir/accounts/migrations/0003_emailchangerequest.py
@@ -1,20 +1,22 @@
-# Generated by Django 3.2.16 on 2022-12-02 11:36
+# Generated by Django 3.2.17 on 2023-02-03 13:44
+from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import functools
+import tapir.accounts.models
import tapir.core.models
class Migration(migrations.Migration):
dependencies = [
- ("wirgarten", "0035_receivedcoopshareslogentry"),
+ ("accounts", "0002_initial"),
]
operations = [
migrations.CreateModel(
- name="WaitingListEntry",
+ name="EmailChangeRequest",
fields=[
(
"id",
@@ -29,27 +31,26 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
- ("first_name", models.CharField(max_length=256)),
- ("last_name", models.CharField(max_length=256)),
- ("email", models.CharField(max_length=256)),
(
- "type",
+ "new_email",
+ models.CharField(max_length=150, verbose_name="New Email"),
+ ),
+ (
+ "secret",
models.CharField(
- choices=[
- ("HARVEST_SHARES", "Ernteanteile"),
- ("COOP_SHARES", "Genossenschaftsanteile"),
- ],
- max_length=32,
+ default=functools.partial(
+ tapir.accounts.models.generate_random_secret, *(), **{}
+ ),
+ max_length=36,
+ verbose_name="Secret",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
- ("privacy_consent", models.DateTimeField()),
(
- "member",
+ "user",
models.ForeignKey(
- null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
- to="wirgarten.member",
+ to=settings.AUTH_USER_MODEL,
),
),
],
diff --git a/tapir/accounts/models.py b/tapir/accounts/models.py
index 3b9f2a42..2162a1e1 100644
--- a/tapir/accounts/models.py
+++ b/tapir/accounts/models.py
@@ -1,219 +1,198 @@
+import base64
+import json
import logging
+from functools import partial
-import ldap
-import ldapdb.models
-import ldapdb.models.fields as ldapdb_fields
-import pyasn1.codec.ber.encoder
-import pyasn1.type.namedtype
-import pyasn1.type.univ
from django.conf import settings
from django.contrib.auth.models import AbstractUser, UserManager
-from django.contrib.auth.models import Permission as PermissionModel
from django.contrib.auth.tokens import default_token_generator
-from django.contrib.auth import get_user_model
from django.core.mail import EmailMultiAlternatives
-from django.db import connections, router, models, transaction
+from django.db import models, transaction
from django.template import loader
from django.urls import reverse
+from django.urls import reverse_lazy
from django.utils import translation
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext_lazy as _
+from keycloak import (
+ KeycloakAdmin,
+)
+from nanoid import generate
from phonenumber_field.modelfields import PhoneNumberField
-from keycloak import KeycloakAdmin, KeycloakOpenID, KeycloakAuthenticationError, KeycloakPostError
from tapir import utils
-from tapir.accounts import validators
+from tapir.configuration.parameter import get_parameter_value
+from tapir.core.models import generate_id, ID_LENGTH, TapirModel
from tapir.log.models import UpdateModelLogEntry
-from tapir.settings import PERMISSIONS
from tapir.utils.models import CountryField
from tapir.utils.user_utils import UserUtils
+from tapir.wirgarten.parameters import Parameter
log = logging.getLogger(__name__)
-class LdapUser(AbstractUser):
- class Meta:
- abstract = True
-
- def get_ldap(self):
- return LdapPerson.objects.get(uid=self.get_username())
+class KeycloakUserQuerySet(models.QuerySet):
+ def delete(self, *args, **kwargs):
+ for obj in self:
+ obj.delete()
- def has_ldap(self):
- result = LdapPerson.objects.filter(uid=self.get_username())
- return len(result) == 1
+ super().delete(*args, **kwargs)
- def create_ldap(self):
- username = self.username
- LdapPerson.objects.create(uid=username, sn=username, cn=username)
- def set_ldap_password(self, raw_password):
- ldap_person = self.get_ldap()
- ldap_person.change_password(raw_password)
+class KeycloakUserManager(models.Manager.from_queryset(KeycloakUserQuerySet)):
+ def normalize_email(self, email: str) -> str:
+ return email.strip()
- def save(
- self, force_insert=False, force_update=False, using=None, update_fields=None
- ):
-
- if self.has_ldap():
- ldap_user = self.get_ldap()
- else:
- ldap_user = LdapPerson(uid=self.username)
- ldap_user.sn = self.last_name or self.username
- ldap_user.cn = self.get_full_name() or self.username
- ldap_user.mail = self.email
- ldap_user.save()
+class KeycloakUser(AbstractUser):
+ objects = KeycloakUserManager()
- super(LdapUser, self).save(force_insert, force_update, using, update_fields)
+ _kk: KeycloakAdmin = None
+ roles: [str] = []
+ email_verified = False
- # force null Django password (will use LDAP password instead)
- self.set_unusable_password()
+ id = models.CharField(
+ "ID",
+ max_length=ID_LENGTH,
+ unique=True,
+ primary_key=True,
+ default=partial(generate_id),
+ )
+ keycloak_id = models.CharField(
+ max_length=64, unique=True, primary_key=False, null=True
+ )
- def delete(self):
- self.get_ldap().delete()
- super(LdapUser, self).delete()
+ def get_keycloak_client(self):
+ # if not self._kk:
+ config = settings.KEYCLOAK_ADMIN_CONFIG
- def set_password(self, raw_password):
- # force null Django password (will use LDAP password)
- self.set_unusable_password()
- if self.has_ldap():
- self.set_ldap_password(raw_password)
+ # self._kk = KeycloakAdmin(
+ return KeycloakAdmin(
+ server_url=config["SERVER_URL"] + "/auth",
+ client_id=config["CLIENT_ID"],
+ realm_name=config["REALM_NAME"],
+ user_realm_name=config["USER_REALM_NAME"],
+ client_secret_key=config["CLIENT_SECRET_KEY"],
+ verify=True,
+ )
- def check_password(self, raw_password):
- return self.get_ldap().check_password(raw_password)
+ # return self._kk
def has_perm(self, perm, obj=None):
- user_dn = self.get_ldap().build_dn()
- # TODO(Leon Handreke): This is a case of very aggressive programming, we require both the perm to
- # be defined in settings and the group to exist. Probably a fair expectation, but explode more
- # gracefully.
- # We use a custom permission system based on statically-defined permissions in settings for
- # these reasons:
- # 1. Easier to keep an overview of what group is allowed to do what
- # 2. Permissions must not be tied to models and can therefore be more broad and simple
- #
- # TODO(Leon Handreke): Taking the group from LDAP is probably not the smartest move because
- # I'm about the only person comfortable to use Apache Directory Studio. Move this into
- # out app and build a nice group management interface?
- for group_cn in settings.PERMISSIONS.get(perm, []):
- if LdapGroup.objects.filter(cn=group_cn).count() == 0:
- continue
- group = LdapGroup.objects.get(cn=group_cn)
- if user_dn in group.members:
- return True
- return super().has_perm(perm=perm, obj=obj)
-
+ return perm in self.roles
+
@transaction.atomic
- def add_perm(self, perm):
- user_dn = self.get_ldap().build_dn()
- permissions = settings.PERMISSIONS.get(perm, [])
- if not permissions:
- if not isinstance(perm, PermissionModel):
- perm = PermissionModel.objects.get(codename=perm)
- self.user_permissions.add(perm)
-
- else:
- for group_cn in permissions:
- group, _ = LdapGroup.objects.get_or_create(
- cn=group_cn,
- defaults={"dn": f"cn={group_cn},{settings.REG_GROUP_BASE_DN}", "members": [user_dn]}
+ def save(self, *args, **kwargs):
+ if self.keycloak_id is None: # Keycloak User does not exist yet --> create
+ kk = self.get_keycloak_client()
+
+ data = {
+ "username": self.email,
+ "email": self.email,
+ "firstName": self.first_name,
+ "lastName": self.last_name,
+ "enabled": True,
+ }
+ print("Creating Keycloak user: ", data)
+
+ initial_password = kwargs.pop("initial_password", None)
+ if initial_password:
+ data["credentials"] = [{"value": initial_password, "type": "password"}]
+ data["emailVerified"] = True
+ else:
+ data["requiredActions"] = ["VERIFY_EMAIL", "UPDATE_PASSWORD"]
+
+ if self.is_superuser:
+ group = kk.get_group_by_path(path="/superuser")
+ if group:
+ data["groups"] = ["superuser"]
+
+ user_id = kk.create_user(data)
+
+ try:
+ kk.send_verify_email(user_id=user_id)
+ except Exception as e:
+ # FIXME: schedule to try again later?
+ print(
+ f"Failed to send verify email to new user: ",
+ e,
+ " (email: '{self.email}', id: '{self.id}', keycloak_id: '{user_id}'): ",
)
- if user_dn not in group.members:
- group.members.append(user_dn)
- group.save()
-
-
-
-class TapirUserQuerySet(models.QuerySet):
- def with_shift_attendance_mode(self, attendance_mode: str):
- return self.filter(shift_user_data__attendance_mode=attendance_mode)
-
- def registered_to_shift_slot_name(self, slot_name: str):
- return self.filter(
- shift_attendance_templates__slot_template__name=slot_name
- ).distinct()
-
- def registered_to_shift_slot_with_capability(self, capability: str):
- return self.filter(
- shift_attendance_templates__slot_template__required_capabilities__contains=[
- capability
- ]
- ).distinct()
-
- def has_capability(self, capability: str):
- return self.filter(
- shift_user_data__capabilities__contains=[capability]
- ).distinct()
-
-class TapirUserManager(UserManager.from_queryset(TapirUserQuerySet)):
- use_in_migrations = True
-
-
-
-class KeycloakUserApiMixin:
-
- def check_password(self, raw_password):
- config = settings.KEYCLOAK_CONFIG
- kk = KeycloakOpenID(
- server_url=config["SERVER_URL"],
- client_id=config["CLIENT_ID"],
- realm_name=config["REALM_NAME"],
- client_secret_key=config["CLIENT_SECRET_KEY"],
+ self.keycloak_id = user_id
+ else: # Update --> change of keycloak data if necessary
+ original = type(self).objects.get(id=self.id)
+ email_changed = original.email != self.email
+ first_name_changed = original.first_name != self.first_name
+ last_name_changed = original.last_name != self.last_name
+
+ if first_name_changed or last_name_changed:
+ kk = self.get_keycloak_client()
+ data = {"firstName": self.first_name, "lastName": self.last_name}
+ kk.update_user(user_id=self.keycloak_id, payload=data)
+
+ if email_changed:
+ self.start_email_change_process(self.email)
+ # important: reset the email to the original email before persisting. The actual change happens after the user click the confirmation link
+ self.email = original.email
+
+ super().save(*args, **kwargs)
+
+ def delete(self, *args, **kwargs):
+ kk = self.get_keycloak_client()
+ if self.keycloak_id:
+ kk.delete_user(self.keycloak_id)
+ super().delete(*args, **kwargs)
+
+ def change_email(self, new_email: str):
+ kk = self.get_keycloak_client()
+ kk.update_user(
+ user_id=self.keycloak_id,
+ payload={
+ "email": new_email,
+ },
)
- try:
- token = kk.token(self.username, raw_password)
- except (KeycloakAuthenticationError, KeycloakPostError):
- return False
- else:
- kk.logout(token['refresh_token'])
- return True
-
- def set_password(self, raw_password):
- config = settings.KEYCLOAK_ADMIN_CONFIG
- kk = KeycloakAdmin(
- server_url=config["SERVER_URL"],
- client_id=config["CLIENT_ID"],
- realm_name=config["REALM_NAME"],
- client_secret_key=config["CLIENT_SECRET_KEY"],
- )
- user_id = kk.get_user_id(self.username)
- if user_id is None:
- raise ValueError("User does not exists")
- kk.set_user_password(user_id=user_id, password=raw_password, temporary=False)
- def has_perm(self, perm, obj=None):
- raise Exception("Implement using Keycloak's API")
-
@transaction.atomic
- def add_perm(self, perm):
- raise Exception("Implement using Keycloak's API")
-
- def delete(self):
- raise Exception("Implement using Keycloak's API")
-
- def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
- raise Exception("Implement using Keycloak's API")
-
+ def start_email_change_process(self, new_email: str):
+ EmailChangeRequest.objects.filter(user_id=self.id).delete()
+ email_change_request = EmailChangeRequest.objects.create(
+ new_email=new_email, user_id=self.id
+ )
+ email_change_token = base64.urlsafe_b64encode(
+ json.dumps(
+ {
+ "new_email": email_change_request.new_email,
+ "secret": email_change_request.secret,
+ "user": email_change_request.user_id,
+ }
+ ).encode()
+ ).decode()
+ email = EmailMultiAlternatives(
+ subject=_("Änderung deiner Email-Adresse"),
+ body=_(
+ f"Hallo {self.first_name}, "
+ f"du hast gerade die Email Adresse für deinen WirGarten Account geändert. "
+ f"Bitte klicke den folgenden Link um die Änderung zu bestätigen: "
+ f"""Email Adresse bestätigen """
+ f"Falls du das nicht warst, kannst du diese Mail einfach löschen oder ignorieren."
+ f" Grüße, dein WirGarten Team"
+ ),
+ to=[new_email],
+ from_email=settings.EMAIL_HOST_SENDER,
+ )
+ email.content_subtype = "html"
+ email.send()
+ class Meta:
+ abstract = True
-class TapirUser(LdapUser):
- username_validator = validators.UsernameValidator()
- # Copy-pasted from django/contrib/auth/models.py to override validators
- username = models.CharField(
- _("Username"),
- max_length=150,
- unique=True,
- help_text=_(
- "Required. 150 characters or fewer. Letters, digits and ./-/_ only."
- ),
- validators=[username_validator],
- error_messages={
- "unique": _("A user with that username already exists."),
- },
- )
+class TapirUser(KeycloakUser):
+ first_name = models.CharField(_("First Name"), max_length=150, blank=False)
+ last_name = models.CharField(_("Last Name"), max_length=150, blank=False)
+ email = models.CharField(_("Email"), max_length=150, blank=False)
phone_number = PhoneNumberField(_("Phone number"), blank=True)
birthdate = models.DateField(_("Birthdate"), blank=True, null=True)
street = models.CharField(_("Street and house number"), max_length=150, blank=True)
@@ -229,7 +208,17 @@ class TapirUser(LdapUser):
max_length=16,
)
- objects = TapirUserManager()
+ # objects = TapirUserManager()
+
+ @transaction.atomic
+ def save(self, *args, **kwargs):
+ self.username = self.email
+ super().save(*args, **kwargs) # call the parent save method
+
+ @transaction.atomic
+ def change_email(self, new_email: str):
+ TapirUser.objects.filter(id=self.id).update(email=new_email, username=new_email)
+ super().change_email(new_email)
def get_display_name(self):
return UserUtils.build_display_name(self.first_name, self.last_name)
@@ -240,7 +229,7 @@ def get_display_address(self):
)
def get_absolute_url(self):
- return reverse("accounts:user_detail", args=[self.pk])
+ return reverse("wirgarten:member_detail", args=[self.pk])
def get_email_from_template(
self, subject_template_names: list, email_template_names: list
@@ -261,189 +250,32 @@ def get_email_from_template(
email.content_subtype = "html"
return email
- def has_perm(self, perm, obj=None):
- # This is a hack to allow permissions based on client certificates. ClientPermsMiddleware checks the
- # certificate in the request and adds the extra permissions the user object, which is accessible here.
- if hasattr(self, "client_perms") and perm in self.client_perms:
- return True
- return super().has_perm(perm=perm, obj=obj)
-
- def get_permissions_display(self):
- user_perms = [perm for perm in PERMISSIONS if self.has_perm(perm)]
- if len(user_perms) == 0:
- return _("None")
- return ", ".join(user_perms)
-
- def save(self, *args, **kwargs):
- self.username_validator(self.username)
- return super().save(*args, **kwargs)
-
-
-
-class UpdateTapirUserLogEntry(UpdateModelLogEntry):
- template_name = "accounts/log/update_tapir_user_log_entry.html"
- excluded_fields = ["password"]
-
-
-# The following LDAP-related models were taken from
-# https://source.puri.sm/liberty/host/middleware/-/blob/master/ldapregister/models.py
-# https://github.com/django-ldapdb/django-ldapdb/blob/master/ldapdb/backends/ldap/base.py
-class LdapPerson(ldapdb.models.Model):
- """
- Class for representing an LDAP person entry.
- """
-
- class Meta:
- verbose_name = "LDAP person"
- verbose_name_plural = "LDAP people"
-
- # LDAP meta-data
- base_dn = settings.REG_PERSON_BASE_DN
- object_classes = settings.REG_PERSON_OBJECT_CLASSES
-
- # Minimal attributes
- uid = ldapdb_fields.CharField(db_column="uid", max_length=200, primary_key=True)
- cn = ldapdb_fields.CharField(db_column="cn", max_length=200)
- sn = ldapdb_fields.CharField(db_column="sn", max_length=200)
- mail = ldapdb_fields.CharField(db_column="mail", max_length=200)
-
- def __str__(self):
- return self.uid
-
- def __unicode__(self):
- return self.uid
-
- def change_password(self, raw_password, using=None):
- # dig into the ldapdb primitives
- using = using or router.db_for_write(self.__class__, instance=self)
- connection = connections[using]
- cursor = connection._cursor()
-
- # call pyldap_orm password modification
- cursor.connection.extop_s(PasswordModify(self.dn, raw_password))
-
- def check_password(self, raw_password, using=None):
- using = using or router.db_for_write(self.__class__, instance=self)
- conn_params = connections[using].get_connection_params()
-
- # This is copy-pasta from django-ldapdb/ldapdb/backends/ldap/base.py
- connection = ldap.ldapobject.ReconnectLDAPObject(
- uri=conn_params["uri"],
- retry_max=conn_params["retry_max"],
- retry_delay=conn_params["retry_delay"],
- bytes_mode=False,
- )
- options = conn_params["options"]
- for opt, value in options.items():
- if opt == "query_timeout":
- connection.timeout = int(value)
- elif opt == "page_size":
- self.page_size = int(value)
- else:
- connection.set_option(opt, value)
- if conn_params["tls"]:
- connection.start_tls_s()
-
- # After setting up the connection, we try to authenticate
- try:
- connection.simple_bind_s(self.dn, raw_password)
- except ldap.INVALID_CREDENTIALS:
- return False
+ def delete(self, *args, **kwargs):
+ super().delete(*args, **kwargs)
+ def has_perms(self, perms):
+ for perm in perms:
+ if not self.has_perm(perm, self):
+ return False
return True
-# The following code taken from https://github.com/asyd/pyldap_orm/blob/master/pyldap_orm/controls.py
-# Copyright 2016 Bruno Bonfils
-# SPDX-License-Identifier: Apache-2.0 (no NOTICE file)
-
-
-class PasswordModify(ldap.extop.ExtendedRequest):
- """
- Implements RFC 3062, LDAP Password Modify Extended Operation
- Reference: https://www.ietf.org/rfc/rfc3062.txt
- """
-
- def __init__(self, identity, new, current=None):
- self.requestName = "1.3.6.1.4.1.4203.1.11.1"
- self.identity = identity
- self.new = new
- self.current = current
-
- def encodedRequestValue(self):
- request = self.PasswdModifyRequestValue()
- request.setComponentByName("userIdentity", self.identity)
- if self.current is not None:
- request.setComponentByName("oldPasswd", self.current)
- request.setComponentByName("newPasswd", self.new)
- return pyasn1.codec.ber.encoder.encode(request)
-
- class PasswdModifyRequestValue(pyasn1.type.univ.Sequence):
- """
- PyASN1 representation of:
- PasswdModifyRequestValue ::= SEQUENCE {
- userIdentity [0] OCTET STRING OPTIONAL
- oldPasswd [1] OCTET STRING OPTIONAL
- newPasswd [2] OCTET STRING OPTIONAL }
- """
-
- componentType = pyasn1.type.namedtype.NamedTypes(
- pyasn1.type.namedtype.OptionalNamedType(
- "userIdentity",
- pyasn1.type.univ.OctetString().subtype(
- implicitTag=pyasn1.type.tag.Tag(
- pyasn1.type.tag.tagClassContext,
- pyasn1.type.tag.tagFormatSimple,
- 0,
- )
- ),
- ),
- pyasn1.type.namedtype.OptionalNamedType(
- "oldPasswd",
- pyasn1.type.univ.OctetString().subtype(
- implicitTag=pyasn1.type.tag.Tag(
- pyasn1.type.tag.tagClassContext,
- pyasn1.type.tag.tagFormatSimple,
- 1,
- )
- ),
- ),
- pyasn1.type.namedtype.OptionalNamedType(
- "newPasswd",
- pyasn1.type.univ.OctetString().subtype(
- implicitTag=pyasn1.type.tag.Tag(
- pyasn1.type.tag.tagClassContext,
- pyasn1.type.tag.tagFormatSimple,
- 2,
- )
- ),
- ),
- )
-
-
-class LdapGroup(ldapdb.models.Model):
- """
- Class for representing an LDAP group entry.
- """
-
- class Meta:
- verbose_name = "LDAP group"
- verbose_name_plural = "LDAP groups"
+def generate_random_secret():
+ return generate(size=36)
- # LDAP meta-data
- base_dn = settings.REG_GROUP_BASE_DN
- object_classes = settings.REG_GROUP_OBJECT_CLASSES
- # LDAP group attributes
- cn = ldapdb_fields.CharField(db_column="cn", max_length=200, primary_key=True)
- description = ldapdb_fields.CharField(db_column="description", max_length=200)
- members = ldapdb_fields.ListField(db_column="member")
+class EmailChangeRequest(TapirModel):
+ user = models.ForeignKey(TapirUser, on_delete=models.DO_NOTHING, null=False)
+ new_email = models.CharField(_("New Email"), max_length=150, blank=False)
+ secret = models.CharField(
+ _("Secret"), max_length=36, default=partial(generate_random_secret)
+ )
+ created_at = models.DateTimeField(auto_now_add=True, null=False)
- def __str__(self):
- return self.cn
- def __unicode__(self):
- return self.cn
+class UpdateTapirUserLogEntry(UpdateModelLogEntry):
+ template_name = "accounts/log/update_tapir_user_log_entry.html"
+ excluded_fields = ["password"]
def language_middleware(get_response):
@@ -456,18 +288,3 @@ def middleware(request):
return response
return middleware
-
-
-class KeycloakUser(models.Model):
- user = models.OneToOneField(get_user_model(), on_delete=models.CASCADE, related_name='keycloak_user')
-
- sub = models.CharField(max_length=255, unique=True)
- realm = models.CharField(max_length=255)
-
-
- class Meta:
- constraints = [
- models.UniqueConstraint(fields=['sub', 'realm'], name='unique_sub_realm')
- ]
-
-
\ No newline at end of file
diff --git a/tapir/accounts/static/accounts/silent-refresh.html b/tapir/accounts/static/accounts/silent-refresh.html
new file mode 100644
index 00000000..4656ce95
--- /dev/null
+++ b/tapir/accounts/static/accounts/silent-refresh.html
@@ -0,0 +1,7 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/tapir/accounts/templates/accounts/email/welcome_email.default.html b/tapir/accounts/templates/accounts/email/welcome_email.default.html
deleted file mode 100644
index 2c0a12bb..00000000
--- a/tapir/accounts/templates/accounts/email/welcome_email.default.html
+++ /dev/null
@@ -1,41 +0,0 @@
-{% load i18n %}
-
-
-
-
-
-
-
-{% blocktranslate with user_display_name=tapir_user.get_display_name %}
- Dear {{ user_display_name }},
-
- we've just created an account for you in our {{ coop_name }} member system. Here you can view your upcoming shifts,
- book an additional shift if you'd like or mark your shift as “looking for a stand-in” (for example when you go
- on vacation).
-
-{% endblocktranslate %}
-
-
- {% if tapir_user.shift_user_data.attendance_mode == "regular" %}
- {% if tapir_user.shift_attendance_templates.first %}
- {% blocktranslate with slot_display_name=tapir_user.shift_attendance_templates.first.slot_template.get_display_name %}
- Your regular ABCD-shift is: {{ slot_display_name }}. You will receive a reminder email in advance of your first shift.
- {% endblocktranslate %}
- {% endif %}
- {% else %}
- {% blocktranslate %}
- Flying members: Please keep in mind that you must have at least one shift “banked” for each shift cycle.
- {% endblocktranslate %}
- {% endif %}
-
-
-{% url "password_reset_confirm" uidb64=uid token=token as password_reset_confirm_url %}
-{% url "password_reset" as password_reset_request_url %}
-
- {% blocktranslate with username=tapir_user.username %}
-
Your username is {{ username }} . In order to log in to your account, you first have to set a password: Click here to set your password
- This link is only valid for a few weeks. Should it expire, you can get a new one here : Click here to get a new link
- {% endblocktranslate %}
-
-
-
diff --git a/tapir/accounts/templates/accounts/email/welcome_email.html b/tapir/accounts/templates/accounts/email/welcome_email.html
deleted file mode 100644
index f932d20a..00000000
--- a/tapir/accounts/templates/accounts/email/welcome_email.html
+++ /dev/null
@@ -1,47 +0,0 @@
-{% load i18n %}
-
-
-
-
-
- {% translate 'Welcome' %}
-
-
-{% blocktranslate with user_display_name=tapir_user.get_display_name %}
- Dear {{ user_display_name }},
- we just created an account for you in our SuperCoop member system. Here you can view your upcoming shifts, book an additional shift if you'd like or mark your shift as “looking for a stand-in” (for example when you go on vacation).
- Please see the Member Manual section III for more information on the Stand-in System.
-{% endblocktranslate %}
-
-
- {% if tapir_user.shift_user_data.attendance_mode == "regular" %}
- {% if tapir_user.shift_attendance_templates.first %}
- {% blocktranslate with slot_display_name=tapir_user.shift_attendance_templates.first.slot_template.get_display_name %}
- Your regular ABCD-shift is: {{ slot_display_name }}. You will receive a reminder email in advance of your first shift.
- {% endblocktranslate %}
- {% endif %}
- {% else %}
- {% blocktranslate %}
- Flying members: Please keep in mind that you must have at least one shift “banked” for each shift cycle. For more information please see the Member Manual .
- {% endblocktranslate %}
- {% endif %}
-
-
-{% url "password_reset_confirm" uidb64=uid token=token as password_reset_confirm_url %}
-{% url "password_reset" as password_reset_request_url %}
-
- {% blocktranslate with username=tapir_user.username %}
-
Your username is *{{ username }}* . In order to login to your account, you first have to set a password : Click here to set your password
- This link is only valid for a few weeks. Should it expire, you can get a new one here : Click here to get a new link
- You can also login to the SuperCoop wiki with that account.
- Alternatively, as for any other question, you can always contact the Member Office
- {% endblocktranslate %}
-
-
- {% blocktranslate %}
- Cooperative regards,
- Your SuperCoop Berlin Member Office team.
- {% endblocktranslate %}
-
-
-
diff --git a/tapir/accounts/templates/accounts/email/welcome_email_subject.default.html b/tapir/accounts/templates/accounts/email/welcome_email_subject.default.html
deleted file mode 100644
index 15f830fa..00000000
--- a/tapir/accounts/templates/accounts/email/welcome_email_subject.default.html
+++ /dev/null
@@ -1,2 +0,0 @@
-{% load i18n %}
-{% translate "Your account in the member system" %}
\ No newline at end of file
diff --git a/tapir/accounts/templates/accounts/keycloak.html b/tapir/accounts/templates/accounts/keycloak.html
new file mode 100644
index 00000000..e05ea9df
--- /dev/null
+++ b/tapir/accounts/templates/accounts/keycloak.html
@@ -0,0 +1,14 @@
+{% load tapir_static %}
+
+
+
+ {% include 'accounts/keycloak_script.html' %}
+
+
+
+
+
+ {% include 'wirgarten/generic/loading-spinner.html' %}
+
+
+
\ No newline at end of file
diff --git a/tapir/accounts/templates/accounts/keycloak_script.html b/tapir/accounts/templates/accounts/keycloak_script.html
new file mode 100644
index 00000000..e772284b
--- /dev/null
+++ b/tapir/accounts/templates/accounts/keycloak_script.html
@@ -0,0 +1,101 @@
+{% load keycloak %}
+{% keycloak_config as conf %}
+
+
+
\ No newline at end of file
diff --git a/tapir/accounts/templates/accounts/link_expired.html b/tapir/accounts/templates/accounts/link_expired.html
new file mode 100644
index 00000000..05277820
--- /dev/null
+++ b/tapir/accounts/templates/accounts/link_expired.html
@@ -0,0 +1,21 @@
+{% load tapir_static %}
+
+
+
+
+
+
+
+
+
+
Ups, dieser Link scheint nicht gültig zu sein.
+
+ {% include 'wirgarten/generic/loading-spinner.html' %}
+
+ Du wirst gleich weitergeleitet...
+
+
+
\ No newline at end of file
diff --git a/tapir/accounts/templates/accounts/password_update.html b/tapir/accounts/templates/accounts/password_update.html
new file mode 100644
index 00000000..e85e2d98
--- /dev/null
+++ b/tapir/accounts/templates/accounts/password_update.html
@@ -0,0 +1,13 @@
+
+
+{% include 'accounts/keycloak.html' %}
+
+
+redirecting...
+
+
diff --git a/tapir/accounts/templates/accounts/user_detail.html b/tapir/accounts/templates/accounts/user_detail.html
deleted file mode 100644
index 2e895190..00000000
--- a/tapir/accounts/templates/accounts/user_detail.html
+++ /dev/null
@@ -1,106 +0,0 @@
-{% extends "accounts/base.html" %}
-
-{% load django_bootstrap5 %}
-{% load tapir_static %}
-{% load i18n %}
-{% load log %}
-{% load accounts %}
-
-{% block head %}
- {{ block.super }}
-
-{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
-
{% translate "Name" %}:
-
{{ object.get_display_name }}
-
-
-
{% translate "Username" %}:
-
{{ object.username }}
-
-
-
{% translate "Email" %}:
-
{{ object.email }}
-
-
-
{% translate "Phone number" %}:
-
- {% if object.phone_number %}
- {{ object.phone_number|format_phone_number }}
- {% else %}
- {% translate "Missing" %}
- {% endif %}
-
-
-
-
{% translate "Birthdate" %}:
-
- {% if object.birthdate %}
- {{ object.birthdate|date:"d.m.Y" }}
- {% else %}
- {% translate "Missing" %}
- {% endif %}
-
-
-
-
{% translate "Address" %}:
-
- {% if object.street and object.city %}
- {{ object.get_display_address }}
- {% else %}
- {% translate "Missing" %}
- {% endif %}
-
-
-
-
{% translate "Preferred Language" %}:
-
{{ object.get_preferred_language_display }}
-
- {% if perms.accounts.manage %}
-
-
{% translate "Permissions" %}:
-
{{ object.get_permissions_display }}
-
- {% endif %}
-
-
-
-
-
-
-
- {% user_log_entry_list object %}
-
-
-{% endblock %}
-
diff --git a/tapir/accounts/templates/accounts/user_form.html b/tapir/accounts/templates/accounts/user_form.html
deleted file mode 100644
index 76133604..00000000
--- a/tapir/accounts/templates/accounts/user_form.html
+++ /dev/null
@@ -1,35 +0,0 @@
-{% extends "accounts/base.html" %}
-
-{% load django_bootstrap5 %}
-{% load i18n %}
-
-{% block content %}
-
-
-
-
-
-
-
- {{ form.instance.get_display_name }}
-
-
-
-
-
-{% endblock %}
diff --git a/tapir/accounts/templates/registration/email/password_reset_email.html b/tapir/accounts/templates/registration/email/password_reset_email.html
deleted file mode 100644
index e7f24839..00000000
--- a/tapir/accounts/templates/registration/email/password_reset_email.html
+++ /dev/null
@@ -1,25 +0,0 @@
-{% load i18n %}
-
-
-
-
-
- {% translate 'Password reset' %}
-
-
-
-{% url 'password_reset_confirm' uidb64=uid token=token as password_reset_url %}
-
-{% language user.preferred_language %}
- {% blocktranslate with first_name=user.first_name username=user.username %}
- Hi {{ first_name }},
-
-
- Someone asked for password reset for {{ email }}.
- Your username is {{ username }}
- Follow this link to reset your password: {{ protocol }}://{{ domain }}{{ password_reset_url }}
-
- {% endblocktranslate %}
-{% endlanguage %}
-
-
\ No newline at end of file
diff --git a/tapir/accounts/templates/registration/email/password_reset_subject.html b/tapir/accounts/templates/registration/email/password_reset_subject.html
deleted file mode 100644
index 7e7506af..00000000
--- a/tapir/accounts/templates/registration/email/password_reset_subject.html
+++ /dev/null
@@ -1,4 +0,0 @@
-{% load i18n %}
-{% blocktranslate %}
- Reset your password
-{% endblocktranslate %}
\ No newline at end of file
diff --git a/tapir/accounts/templates/registration/login.html b/tapir/accounts/templates/registration/login.html
deleted file mode 100644
index 20246d8e..00000000
--- a/tapir/accounts/templates/registration/login.html
+++ /dev/null
@@ -1,70 +0,0 @@
-{% extends "core/base.html" %}
-
-{% load django_bootstrap5 %}
-{% load tapir_static %}
-{% load i18n %}
-
-{% block head %}
- {{ block.super }}
-
-{% endblock %}
-
-{% block content %}
-
-
-
-{% endblock %}
diff --git a/tapir/accounts/templates/registration/password_reset_complete.html b/tapir/accounts/templates/registration/password_reset_complete.html
deleted file mode 100644
index c21f4b32..00000000
--- a/tapir/accounts/templates/registration/password_reset_complete.html
+++ /dev/null
@@ -1,15 +0,0 @@
-{% extends "core/base.html" %}
-
-{% load django_bootstrap5 %}
-{% load tapir_static %}
-{% load i18n %}
-
-{% block content %}
-
-{% endblock %}
diff --git a/tapir/accounts/templates/registration/password_reset_confirm.html b/tapir/accounts/templates/registration/password_reset_confirm.html
deleted file mode 100644
index c75d4b68..00000000
--- a/tapir/accounts/templates/registration/password_reset_confirm.html
+++ /dev/null
@@ -1,25 +0,0 @@
-{% extends "core/base.html" %}
-
-{% load django_bootstrap5 %}
-{% load tapir_static %}
-{% load i18n %}
-
-{% block content %}
- {% if validlink %}
-
-
-
-
{% translate "Please enter a new password" %}
-
-
-
- {% else %}
-
- {% translate "The password reset link was invalid, possibly because it has already been used. Please request a new password reset." %}
-
- {% endif %}
-{% endblock %}
diff --git a/tapir/accounts/templates/registration/password_reset_done.html b/tapir/accounts/templates/registration/password_reset_done.html
deleted file mode 100644
index b68f5af4..00000000
--- a/tapir/accounts/templates/registration/password_reset_done.html
+++ /dev/null
@@ -1,14 +0,0 @@
-{% extends "core/base.html" %}
-
-{% load django_bootstrap5 %}
-{% load tapir_static %}
-{% load i18n %}
-
-{% block content %}
-
-
{% translate "Password reset instructions have been sent." %}
-
- {% translate "We've emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly. If you don't receive an email, please make sure you've entered the address you registered with, and check your spam folder." %}
-
-
-{% endblock %}
diff --git a/tapir/accounts/templates/registration/password_reset_form.html b/tapir/accounts/templates/registration/password_reset_form.html
deleted file mode 100644
index b62267fe..00000000
--- a/tapir/accounts/templates/registration/password_reset_form.html
+++ /dev/null
@@ -1,26 +0,0 @@
-{% extends "core/base.html" %}
-
-{% load django_bootstrap5 %}
-{% load tapir_static %}
-{% load i18n %}
-
-{% block content %}
-
-{% endblock %}
diff --git a/tapir/accounts/templates/registration/password_update.html b/tapir/accounts/templates/registration/password_update.html
deleted file mode 100644
index 4b51f77a..00000000
--- a/tapir/accounts/templates/registration/password_update.html
+++ /dev/null
@@ -1,25 +0,0 @@
-{% extends "accounts/base.html" %}
-
-{% load django_bootstrap5 %}
-{% load i18n %}
-
-{% block content %}
-
-{% endblock %}
-
diff --git a/tapir/accounts/templatetags/keycloak.py b/tapir/accounts/templatetags/keycloak.py
new file mode 100644
index 00000000..02ff0144
--- /dev/null
+++ b/tapir/accounts/templatetags/keycloak.py
@@ -0,0 +1,24 @@
+import json
+
+from django import template
+
+from tapir import settings
+
+register = template.Library()
+
+
+@register.simple_tag
+def keycloak_config() -> str:
+ return json.dumps(
+ {
+ "url": settings.KEYCLOAK_ADMIN_CONFIG["PUBLIC_URL"],
+ "realm": settings.KEYCLOAK_ADMIN_CONFIG["USER_REALM_NAME"],
+ "clientId": settings.KEYCLOAK_ADMIN_CONFIG["FRONTEND_CLIENT_ID"],
+ "publicClient": True,
+ }
+ )
+
+
+@register.simple_tag
+def keycloak_public_url() -> str:
+ return settings.KEYCLOAK_ADMIN_CONFIG["PUBLIC_URL"]
diff --git a/tapir/accounts/tests/factories/factories.py b/tapir/accounts/tests/factories/factories.py
index cfa424fa..d7e78c81 100644
--- a/tapir/accounts/tests/factories/factories.py
+++ b/tapir/accounts/tests/factories/factories.py
@@ -1,7 +1,6 @@
import factory
-from tapir import settings
-from tapir.accounts.models import TapirUser, LdapGroup
+from tapir.accounts.models import TapirUser
from tapir.accounts.tests.factories.user_data_factory import UserDataFactory
@@ -10,27 +9,3 @@ class Meta:
model = TapirUser
username = factory.LazyAttribute(lambda o: f"{o.first_name}.{o.last_name}")
-
- @factory.post_generation
- def password(self, create, password, **kwargs):
- if not create:
- return
- self.set_password(password or self.username)
-
- @factory.post_generation
- def is_in_member_office(self, create, is_in_member_office, **kwargs):
- if not create:
- return
-
- group_cn = settings.GROUP_MEMBER_OFFICE
- group = LdapGroup.objects.get(cn=group_cn)
- user_dn = self.get_ldap().build_dn()
- if is_in_member_office:
- group.members.append(user_dn)
- group.save()
- elif user_dn in group.members:
- # The current test setup uses the same LDAP server for all the tests, without resetting it in between tests,
- # so we have to make sure that this user has not been added to the member office by a previous test
- # or a previous run
- group.members.remove(user_dn)
- group.save()
diff --git a/tapir/accounts/tests/test_integration.py b/tapir/accounts/tests/test_integration.py
index 470b2d7c..7656db00 100644
--- a/tapir/accounts/tests/test_integration.py
+++ b/tapir/accounts/tests/test_integration.py
@@ -1,20 +1,22 @@
-from django.test import tag
-
-from tapir.utils.tests_utils import TapirSeleniumTestBase
-
-
-class AccountsIntegrationTests(TapirSeleniumTestBase):
- @tag("selenium")
- def test_login_as_admin(self):
- self.selenium.get(self.live_server_url)
- self.logout_if_necessary()
- self.login_as_admin()
- self.assertIsNotNone(self.selenium.find_element_by_id("logout"))
-
- @tag("selenium")
- def test_redirect_to_login_page(self):
- self.selenium.get(self.live_server_url)
- self.logout_if_necessary()
- self.selenium.get(f"{self.live_server_url}/config/parameters")
- url = str(self.selenium.current_url)
- self.assertTrue("/accounts/login/" in url)
+# FIXME
+# from django.test import tag
+#
+# from tapir.utils.tests_utils import TapirSeleniumTestBase
+#
+#
+# class AccountsIntegrationTests(TapirSeleniumTestBase):
+# @tag("selenium")
+# def test_login_as_admin(self):
+# self.selenium.get(self.live_server_url)
+# self.logout_if_necessary()
+# self.login_as_admin()
+# self.assertIsNotNone(self.selenium.find_element_by_id("logout"))
+#
+#
+# @tag("selenium")
+# def test_redirect_to_login_page(self):
+# self.selenium.get(self.live_server_url)
+# self.logout_if_necessary()
+# self.selenium.get(f"{self.live_server_url}/config/parameters")
+# url = str(self.selenium.current_url)
+# self.assertTrue("/accounts/login/" in url)
diff --git a/tapir/accounts/tests/test_keycloak.py b/tapir/accounts/tests/test_keycloak.py
deleted file mode 100644
index 4f288078..00000000
--- a/tapir/accounts/tests/test_keycloak.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from django.contrib.auth import authenticate, login
-from tapir.utils.tests_utils import KeycloakServiceTestCase
-from tapir.accounts.tests.factories.factories import TapirUserFactory
-from django.test import RequestFactory
-
-
-class KeycloakServerBackendAuthenticationTests(KeycloakServiceTestCase):
- """
- This testa are run against the keycloak server within this project
- (Docker server keycloak-server)
- """
- def test_authenticates_user_against_keycloak(self):
- request = RequestFactory().get('/')
- auth_user = authenticate(request, username='demo@demo.com', password='demo')
- self.assertIsNotNone(auth_user)
-
- def test_disabled_cannot_login(self):
- request = RequestFactory().get('/')
- auth_user = authenticate(request, username='inactive@inactive.com', password='inactive')
- self.assertIsNone(auth_user)
-
- def test_differentiates_between_different_realms(self):
- self.fail("user from another realm cannot login")
-
-
-
-class KeycloakServerSignupTests(KeycloakServiceTestCase):
- """
- This testa are run against the keycloak server within this project
- (Docker server keycloak-server)
- """
- def test_users_can_signup_using_username_and_password(self):
- self.fail('caputre signup form nd post it')
\ No newline at end of file
diff --git a/tapir/accounts/tests/test_models.py b/tapir/accounts/tests/test_models.py
deleted file mode 100644
index 7d650337..00000000
--- a/tapir/accounts/tests/test_models.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from unittest import mock
-from django.core.exceptions import ValidationError
-from django.contrib.auth import get_user_model
-from tapir.utils.tests_utils import LadapCleanupTestMixin, KeycloakTestCase
-
-User = get_user_model()
-
-class TapirUserTests(LadapCleanupTestMixin, KeycloakTestCase):
-
- def test_model_validates_usernames(self):
- usernames = [
- "asd$",
- ]
- for username in usernames:
- try:
- User.objects.create_user(username=username)
- except ValidationError:
- continue
- self.fail(f"validation error not raised for username: {username}")
-
- def test_user_can_set_its_password(self):
- user = self.create_user()
- user.set_password("anypassword")
- self.assertTrue(user.check_password("anypassword"))
diff --git a/tapir/accounts/tests/test_registration.py b/tapir/accounts/tests/test_registration.py
index 8bcff410..690ea653 100644
--- a/tapir/accounts/tests/test_registration.py
+++ b/tapir/accounts/tests/test_registration.py
@@ -6,60 +6,60 @@
User = get_user_model()
-class WizardTests(KeycloakTestCase, WebTest):
- def test_simple_signup_flow(self):
- self.has_ldap.return_value = False
- self.ldap_save.return_value = None
-
- form = self.app.get(reverse('wirgarten:draftuser_register')).form
- form["Harvest Shares-harvest_shares_s"] = 1
- r = form.submit()
-
- form = r.form
- form["Cooperative Shares-statute_consent"] = True
- r = form.submit()
-
- form = r.form
- r = form.submit()
-
- form = r.form
- r = form.submit()
-
- form = r.form
- r = form.submit()
-
- form = r.form
- r = form.submit()
-
- form = r.form
- form['Personal Details-first_name'] = 'Firstname'
- form['Personal Details-last_name'] = 'Lastname'
- form['Personal Details-email'] = 'any@mail.com'
- form['Personal Details-phone_number'] = '+12125552368'
- form['Personal Details-street'] = 'fake street'
- form['Personal Details-street_2'] = ''
- form['Personal Details-postcode'] = '12345'
- form['Personal Details-city'] = 'Berlin'
- form['Personal Details-country'] = 'DE'
- form['Personal Details-birthdate'] = ''
- r = form.submit()
-
- form = r.form
- form['Payment Details-account_owner'] = 'Firstname Lastname'
- form['Payment Details-iban'] = 'DE89370400440532013000'
- form['Payment Details-bic'] = 'DEUTDE5M'
- r = form.submit()
-
- form = r.form
- form['Payment Details-sepa_consent'] = True
- r = form.submit()
-
- form = r.form
- form['Consent-withdrawal_consent'] = True
- form['Consent-privacy_consent'] = True
- r = form.submit().follow()
-
- user = User.objects.filter(email='any@mail.com').first()
- self.assertIsNotNone(user)
-
\ No newline at end of file
+# FIXME
+# class WizardTests(KeycloakTestCase, WebTest):
+# def test_simple_signup_flow(self):
+# self.has_ldap.return_value = False
+# self.ldap_save.return_value = None
+#
+# form = self.app.get(reverse("wirgarten:draftuser_register")).form
+# form["Harvest Shares-harvest_shares_s"] = 1
+# r = form.submit()
+#
+# form = r.form
+# form["Cooperative Shares-statute_consent"] = True
+# r = form.submit()
+#
+# form = r.form
+# r = form.submit()
+#
+# form = r.form
+# r = form.submit()
+#
+# form = r.form
+# r = form.submit()
+#
+# form = r.form
+# r = form.submit()
+#
+# form = r.form
+# form["Personal Details-first_name"] = "Firstname"
+# form["Personal Details-last_name"] = "Lastname"
+# form["Personal Details-email"] = "any@mail.com"
+# form["Personal Details-phone_number"] = "+12125552368"
+# form["Personal Details-street"] = "fake street"
+# form["Personal Details-street_2"] = ""
+# form["Personal Details-postcode"] = "12345"
+# form["Personal Details-city"] = "Berlin"
+# form["Personal Details-country"] = "DE"
+# form["Personal Details-birthdate"] = ""
+# r = form.submit()
+#
+# form = r.form
+# form["Payment Details-account_owner"] = "Firstname Lastname"
+# form["Payment Details-iban"] = "DE89370400440532013000"
+# form["Payment Details-bic"] = "DEUTDE5M"
+# r = form.submit()
+#
+# form = r.form
+# form["Payment Details-sepa_consent"] = True
+# r = form.submit()
+#
+# form = r.form
+# form["Consent-withdrawal_consent"] = True
+# form["Consent-privacy_consent"] = True
+# r = form.submit().follow()
+#
+# user = User.objects.filter(email="any@mail.com").first()
+# self.assertIsNotNone(user)
diff --git a/tapir/accounts/tests/test_standard_user_detail_page.py b/tapir/accounts/tests/test_standard_user_detail_page.py
deleted file mode 100644
index 3a557faa..00000000
--- a/tapir/accounts/tests/test_standard_user_detail_page.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from django.test import Client
-from django.urls import reverse
-
-from tapir.accounts.models import TapirUser
-from tapir.accounts.tests.factories.factories import TapirUserFactory
-from tapir.utils.tests_utils import TapirFactoryTestBase
-
-
-class AccountsStandardUserDetailPage(TapirFactoryTestBase):
- def test_standard_user_detail_page(self):
- client = Client()
- user: TapirUser = TapirUserFactory.create()
- self.assertTrue(client.login(username=user.username, password=user.username))
- response = client.get(reverse("accounts:user_me"), follow=True)
- self.assertEqual(
- user.id,
- response.context["object"].id,
- "The logged in user should be the view's context object.",
- )
- self.assertInHTML(
- f"{ user.username }
",
- response.content.decode(),
- )
-
- for button in [
- "tapir_user_edit_button",
- "share_owner_edit_button",
- "add_note_button",
- ]:
- self.assertNotContains(
- response,
- button,
- 200,
- "The user is not in the member office, they should not see the edit buttons",
- )
diff --git a/tapir/accounts/tests/test_views.py b/tapir/accounts/tests/test_views.py
index 5f7ae8ba..9e0673ce 100644
--- a/tapir/accounts/tests/test_views.py
+++ b/tapir/accounts/tests/test_views.py
@@ -1,116 +1,108 @@
-from io import StringIO
-from unittest import mock
-from django_webtest import WebTest
-from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Permission as PermissionModel
-from tapir.accounts.tests.factories.factories import TapirUserFactory
-from tapir.wirgarten.factories import MemberFactory, ShareOwnershipFactory, SubscriptionFactory, PaymentFactory
-from tapir.utils.tests_utils import LadapCleanupTestMixin, KeycloakTestCase
-from tapir.wirgarten.constants import Permission
-from django.urls import reverse
-
-from tapir.configuration.models import TapirParameter
-
-User = get_user_model()
-
-class PermissionsTests(LadapCleanupTestMixin, KeycloakTestCase, WebTest):
-
- def test_send_welcome_mail_form_only_available_with_proper_perm(self):
- user = self.create_user()
- self.assertFalse(user.has_perm('accounts.manage'))
- url = reverse("accounts:user_detail", kwargs={"pk": user.pk})
- r = self.app.get(url, user=user)
- selector = '#send-user-welcome-mail-form'
- self.assertIsNone(r.html.select_one(selector))
-
- user.add_perm('accounts.manage')
- self.assertTrue(user.has_perm('accounts.manage'))
- r = self.app.get(url, user=user)
- self.assertIsNotNone(r.html.select_one(selector))
-
- def test_user_edit_only_available_with_proper_perm(self):
- member = ShareOwnershipFactory().member
- user = self.create_user()
- self.assertFalse(user.has_perm('accounts.manage'))
- url = reverse("wirgarten:member_detail", kwargs={"pk": member.pk})
- r = self.app.get(url, user=user, status=403)
-
- selector = '#tapir_user_edit_button'
- user.add_perm('accounts.manage')
- self.assertTrue(user.has_perm('accounts.manage'))
- r = self.app.get(url, user=user)
- self.assertIsNotNone(r.html.select_one(selector))
-
- def test_edit_payment_form_only_available_with_proper_perm(self):
- member = SubscriptionFactory().member
- p = PaymentFactory(mandate_ref__member=member)
- user = self.create_user()
- self.assertFalse(user.has_perm('coop.manage'))
- url = reverse("wirgarten:member_payments", kwargs={"pk": member.pk})
- r = self.app.get(url, user=user, status=403)
-
- selector = '#edit-payment-form'
- user.add_perm('coop.manage')
- self.assertTrue(user.has_perm('coop.manage'))
- r = self.app.get(url, user=user)
- self.assertIsNotNone(r.html.select_one(selector))
-
- def test_member_edit_button_only_available_with_proper_perm(self):
- member = MemberFactory()
- user = self.create_user()
- self.assertFalse(user.has_perm('accounts.manage'))
- url = reverse("wirgarten:member_list")
- r = self.app.get(url, user=user, status=403)
-
- selector = '#edit-member-details-btn'
- user.add_perm('accounts.manage')
- self.assertTrue(user.has_perm('accounts.manage'))
- r = self.app.get(url, user=user)
- self.assertIsNotNone(r.html.select_one(selector))
-
- def test_trans_coop_shares_button_only_available_with_proper_perm(self):
- member = MemberFactory()
- user = self.create_user()
- self.assertFalse(user.has_perm('coop.manage'))
- url = reverse("wirgarten:member_list")
- r = self.app.get(url, user=user, status=403)
-
- selector = '#trans-coop-shares-btn'
- user.add_perm('accounts.manage')
- self.assertTrue(user.has_perm('coop.manage'))
- r = self.app.get(url, user=user)
- self.assertIsNotNone(r.html.select_one(selector))
-
- def test_product_view_buttons_only_available_with_proper_perm(self):
- user = self.create_user()
- url = reverse("wirgarten:product")
- r = self.app.get(url, user=user, expect_errors=True)
- self.assertEqual(r.status_code, 403)
- user.add_perm(Permission.Products.VIEW)
- self.assertTrue(user.has_perm(Permission.Products.VIEW))
- r = self.app.get(url, user=user)
-
- checks = [
- ('coop.manage', [
- '#growing-period-add-btn',
- '#growing-period-copy-form-btn',
- '#del-growing-period-btn',
- ]),
- (Permission.Products.MANAGE, [
- '#add-capacity-btn',
- '#edit-capacity-btn',
- '#del-capacity-btn',
- '#add-product-btn',
- '#edit-product-btn',
- '#del-product-btn',
- ])
- ]
-
- for perm, selectors in checks:
- user.add_perm(perm)
- self.assertTrue(user.has_perm(perm))
- r = self.app.get(url, user=user)
- for selector in selectors:
- self.assertIsNotNone(r.html.select_one(selector), selectors)
-
-
\ No newline at end of file
+# FIXME
+# from django_webtest import WebTest
+# from django.contrib.auth import get_user_model
+# from tapir.wirgarten.factories import (
+# MemberFactory,
+# ShareOwnershipFactory,
+# SubscriptionFactory,
+# PaymentFactory,
+# )
+# from tapir.utils.tests_utils import KeycloakTestCase
+# from tapir.wirgarten.constants import Permission
+# from django.urls import reverse
+#
+# User = get_user_model()
+#
+#
+# class PermissionsTests( KeycloakTestCase, WebTest):
+#
+# def test_user_edit_only_available_with_proper_perm(self):
+# member = ShareOwnershipFactory().member
+# user = self.create_user()
+# self.assertFalse(user.has_perm("accounts.manage"))
+# url = reverse("wirgarten:member_detail", kwargs={"pk": member.pk})
+# r = self.app.get(url, user=user, status=403)
+#
+# selector = "#tapir_user_edit_button"
+# user.add_perm("accounts.manage")
+# self.assertTrue(user.has_perm("accounts.manage"))
+# r = self.app.get(url, user=user)
+# self.assertIsNotNone(r.html.select_one(selector))
+#
+# def test_edit_payment_form_only_available_with_proper_perm(self):
+# member = SubscriptionFactory().member
+# p = PaymentFactory(mandate_ref__member=member)
+# user = self.create_user()
+# self.assertFalse(user.has_perm("coop.manage"))
+# url = reverse("wirgarten:member_payments", kwargs={"pk": member.pk})
+# r = self.app.get(url, user=user, status=403)
+#
+# selector = "#edit-payment-form"
+# user.add_perm("coop.manage")
+# self.assertTrue(user.has_perm("coop.manage"))
+# r = self.app.get(url, user=user)
+# self.assertIsNotNone(r.html.select_one(selector))
+#
+# def test_member_edit_button_only_available_with_proper_perm(self):
+# member = MemberFactory()
+# user = self.create_user()
+# self.assertFalse(user.has_perm("accounts.manage"))
+# url = reverse("wirgarten:member_list")
+# r = self.app.get(url, user=user, status=403)
+#
+# selector = "#edit-member-details-btn"
+# user.add_perm("accounts.manage")
+# self.assertTrue(user.has_perm("accounts.manage"))
+# r = self.app.get(url, user=user)
+# self.assertIsNotNone(r.html.select_one(selector))
+#
+# def test_trans_coop_shares_button_only_available_with_proper_perm(self):
+# member = MemberFactory()
+# user = self.create_user()
+# self.assertFalse(user.has_perm("coop.manage"))
+# url = reverse("wirgarten:member_list")
+# r = self.app.get(url, user=user, status=403)
+#
+# selector = "#trans-coop-shares-btn"
+# user.add_perm("accounts.manage")
+# self.assertTrue(user.has_perm("coop.manage"))
+# r = self.app.get(url, user=user)
+# self.assertIsNotNone(r.html.select_one(selector))
+#
+# def test_product_view_buttons_only_available_with_proper_perm(self):
+# user = self.create_user()
+# url = reverse("wirgarten:product")
+# r = self.app.get(url, user=user, expect_errors=True)
+# self.assertEqual(r.status_code, 403)
+# user.add_perm(Permission.Products.VIEW)
+# self.assertTrue(user.has_perm(Permission.Products.VIEW))
+# r = self.app.get(url, user=user)
+#
+# checks = [
+# (
+# "coop.manage",
+# [
+# "#growing-period-add-btn",
+# "#growing-period-copy-form-btn",
+# "#del-growing-period-btn",
+# ],
+# ),
+# (
+# Permission.Products.MANAGE,
+# [
+# "#add-capacity-btn",
+# "#edit-capacity-btn",
+# "#del-capacity-btn",
+# "#add-product-btn",
+# "#edit-product-btn",
+# "#del-product-btn",
+# ],
+# ),
+# ]
+#
+# for perm, selectors in checks:
+# user.add_perm(perm)
+# self.assertTrue(user.has_perm(perm))
+# r = self.app.get(url, user=user)
+# for selector in selectors:
+# self.assertIsNotNone(r.html.select_one(selector), selectors)
diff --git a/tapir/accounts/urls.py b/tapir/accounts/urls.py
index a413f532..02c22860 100644
--- a/tapir/accounts/urls.py
+++ b/tapir/accounts/urls.py
@@ -1,68 +1,20 @@
-import django.contrib.auth.views as auth_views
from django.urls import path, include, reverse_lazy
from django.views import generic
-from tapir.accounts import views
-
-
-accounts_urlpatterns = [
- path(
- "", generic.RedirectView.as_view(pattern_name="accounts:user_me"), name="index"
- ),
- path("user/me/", views.UserMeView.as_view(), name="user_me"),
- path("user//", views.UserDetailView.as_view(), name="user_detail"),
- path("user//edit", views.UserUpdateView.as_view(), name="user_update"),
- path(
- "user//send_welcome_email",
- views.send_user_welcome_email,
- name="send_user_welcome_email",
- ),
-]
+from tapir.wirgarten.views.member import change_email
urlpatterns = [
# Standard login/logout/password views should be un-namespaced because Django refers to them in a few places and
# it's easier to do it like this than hunt down all the places and fix the references
- path("", include((accounts_urlpatterns, "accounts"))),
- path(
- "login/",
- auth_views.LoginView.as_view(),
- name="login",
- ),
path(
- "logout/",
- auth_views.logout_then_login,
- name="logout",
- ),
- path(
- "password_change/",
- auth_views.PasswordChangeView.as_view(
- success_url=reverse_lazy("accounts:user_me"),
- template_name="registration/password_update.html",
- ),
+ "password_change",
+ generic.TemplateView.as_view(template_name="accounts/password_update.html"),
name="password_change",
),
+ path("email_change/", change_email, name="change_email_confirm"),
path(
- "password_reset/",
- views.PasswordResetView.as_view(
- html_email_template_name="registration/email/password_reset_email.html",
- subject_template_name="registration/email/password_reset_subject.html",
- ),
- name="password_reset",
- ),
- path(
- "password_reset/done/",
- auth_views.PasswordResetDoneView.as_view(),
- name="password_reset_done",
- ),
- path(
- "reset///",
- auth_views.PasswordResetConfirmView.as_view(),
- name="password_reset_confirm",
- ),
- path(
- "reset/done/",
- auth_views.PasswordResetCompleteView.as_view(),
- name="password_reset_complete",
+ "link_expired",
+ generic.TemplateView.as_view(template_name="accounts/link_expired.html"),
+ name="link_expired",
),
]
-
diff --git a/tapir/configuration/forms.py b/tapir/configuration/forms.py
index b4ac6100..51e5fc3a 100644
--- a/tapir/configuration/forms.py
+++ b/tapir/configuration/forms.py
@@ -1,4 +1,4 @@
-from importlib.resources import _
+from django.utils.translation import gettext_lazy as _
from django import forms
from django.forms import Textarea
diff --git a/tapir/configuration/views.py b/tapir/configuration/views.py
index c8308453..cf3bc145 100644
--- a/tapir/configuration/views.py
+++ b/tapir/configuration/views.py
@@ -9,7 +9,7 @@
class ParameterView(PermissionRequiredMixin, generic.FormView):
template_name = "configuration/parameter_view.html"
- permission_required = "coop.admin"
+ permission_required = "coop.manage"
form_class = ParameterForm
def get_success_url(self, **kwargs):
diff --git a/tapir/core/static/core/css/custom.css b/tapir/core/static/core/css/custom.css
index f7d2df20..832c2a6f 100644
--- a/tapir/core/static/core/css/custom.css
+++ b/tapir/core/static/core/css/custom.css
@@ -230,4 +230,4 @@ tr.tr-href:hover:not(.active), tr.tr-clickable:hover:not(.active){
div.option.selected {
background-color: var(--secondary);
-}
\ No newline at end of file
+}
diff --git a/tapir/core/templates/core/base.html b/tapir/core/templates/core/base.html
index 2633b094..140d3e1c 100644
--- a/tapir/core/templates/core/base.html
+++ b/tapir/core/templates/core/base.html
@@ -17,7 +17,10 @@
-
+
+
+ {% include 'accounts/keycloak_script.html' %}
+
@@ -29,42 +32,48 @@
-
-
+
+ {% include 'wirgarten/generic/loading-spinner.html' %}
+
+
+
- {% if request.user.is_authenticated %}
- {{ request.user.first_name }} {{ request.user.last_name }}
- Logout
- {% else %}
- Login
- {% endif %}
-
+ {% if request.user.is_authenticated %}
+
+ {% endif %}
+
-{% include 'wirgarten/generic/modal/form-modal.html' %}
-{% include 'wirgarten/generic/modal/confirmation-modal.html' %}
+ {% include 'wirgarten/generic/modal/form-modal.html' %}
+ {% include 'wirgarten/generic/modal/confirmation-modal.html' %}
-
-
-
+
+
+ {% if request.user and "coop.view" in request.user.roles %}
+
+ {% endif %}
-
-
- {% bootstrap_messages %}
- {% block content %}{% endblock %}
-
+
+
+ {% bootstrap_messages %}
+ {% block content %}{% endblock %}
+
+
+
-
@@ -79,6 +88,5 @@
elem.classList.add('form-select');
elem.classList.add('is-valid');
}
-