Skip to content
Snippets Groups Projects
Verified Commit e3d82618 authored by Jonathan Weth's avatar Jonathan Weth :keyboard:
Browse files

Merge branch 'master' into 327-add-own-menu-entry-for-notifications

parents ec82cb36 244d300d
No related branches found
No related tags found
1 merge request!447Resolve "Add own menu entry for notifications"
......@@ -2,8 +2,6 @@ from typing import Any, List, Optional, Tuple
import django.apps
from django.apps import apps
from django.conf import settings
from django.db import OperationalError, ProgrammingError
from django.http import HttpRequest
from django.utils.module_loading import autodiscover_modules
......@@ -16,7 +14,7 @@ from .registries import (
site_preferences_registry,
)
from .util.apps import AppConfig
from .util.core_helpers import get_site_preferences, has_person
from .util.core_helpers import has_person
from .util.sass_helpers import clean_scss
......@@ -52,8 +50,6 @@ class CoreConfig(AppConfig):
preference_models.register(personpreferencemodel, person_preferences_registry)
preference_models.register(grouppreferencemodel, group_preferences_registry)
self._refresh_authentication_backends()
self._load_data_checks()
from .health_checks import DataChecksHealthCheckBackend
......@@ -70,24 +66,6 @@ class CoreConfig(AppConfig):
data_checks += getattr(model, "data_checks", [])
DataCheckRegistry.data_checks = data_checks
@classmethod
def _refresh_authentication_backends(cls):
"""Refresh config list of enabled authentication backends."""
from .preferences import AuthenticationBackends # noqa
idx = settings.AUTHENTICATION_BACKENDS.index("django.contrib.auth.backends.ModelBackend")
try:
# Don't set array directly in order to keep object reference
settings._wrapped.AUTHENTICATION_BACKENDS.clear()
settings._wrapped.AUTHENTICATION_BACKENDS += settings.ORIGINAL_AUTHENTICATION_BACKENDS
for backend in get_site_preferences()["auth__backends"]:
settings._wrapped.AUTHENTICATION_BACKENDS.insert(idx, backend)
idx += 1
except (ProgrammingError, OperationalError):
pass
def preference_updated(
self,
sender: Any,
......@@ -97,9 +75,6 @@ class CoreConfig(AppConfig):
new_value: Optional[Any] = None,
**kwargs,
) -> None:
if section == "auth" and name == "backends":
self._refresh_authentication_backends()
if section == "theme":
if name in ("primary", "secondary"):
clean_scss()
......
......@@ -47,6 +47,28 @@ class _ExtensibleModelBase(models.base.ModelBase):
return mcls
def _generate_one_to_one_proxy_property(field, subfield):
def getter(self):
if hasattr(self, field.name):
related = getattr(self, field.name)
return getattr(related, subfield.name)
# Related instane does not exist
return None
def setter(self, val):
if hasattr(self, field.name):
related = getattr(self, field.name)
else:
# Auto-create related instance (but do not save)
related = field.related_model()
setattr(related, field.remote_field.name, self)
# Ensure the related model is saved later
self._save_reverse = getattr(self, "_save_reverse", []) + [related]
setattr(related, subfield.name, val)
return property(getter, setter)
class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase):
"""Base model for all objects in AlekSIS apps.
......@@ -248,13 +270,51 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase):
to.property_(_virtual_related, related_name)
@classmethod
def syncable_fields(cls) -> List[models.Field]:
"""Collect all fields that can be synced on a model."""
return [
field
for field in cls._meta.fields
if (field.editable and not field.auto_created and not field.is_relation)
]
def syncable_fields(
cls, recursive: bool = True, exclude_remotes: List = []
) -> List[models.Field]:
"""Collect all fields that can be synced on a model.
If recursive is True, it recurses into related models and generates virtual
proxy fields to access fields in related models."""
fields = []
for field in cls._meta.get_fields():
if field.is_relation and field.one_to_one and recursive:
if ExtensibleModel not in field.related_model.__mro__:
# Related model is not extensible and thus has no syncable fields
continue
if field.related_model in exclude_remotes:
# Remote is excluded, probably to avoid recursion
continue
# Recurse into related model to get its fields as well
for subfield in field.related_model.syncable_fields(
recursive, exclude_remotes + [cls]
):
# generate virtual field names for proxy access
name = f"_{field.name}__{subfield.name}"
verbose_name = f"{field.name} ({field.related_model._meta.verbose_name}) → {subfield.verbose_name}"
if not hasattr(cls, name):
# Add proxy properties to handle access to related model
setattr(cls, name, _generate_one_to_one_proxy_property(field, subfield))
# Generate a fake field class with enough API to detect attribute names
fields.append(
type(
"FakeRelatedProxyField",
(),
{
"name": name,
"verbose_name": verbose_name,
"to_python": lambda v: subfield.to_python(v),
},
)
)
elif field.editable and not field.auto_created:
fields.append(field)
return fields
@classmethod
def syncable_fields_choices(cls) -> Tuple[Tuple[str, str]]:
......@@ -273,6 +333,16 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase):
"""Dynamically add a new permission to a model."""
cls.extra_permissions.append((name, verbose_name))
def save(self, *args, **kwargs):
"""Ensure all functionality of our extensions that needs saving gets it."""
# For auto-created remote syncable fields
if hasattr(self, "_save_reverse"):
for related in self._save_reverse:
related.save()
del self._save_reverse
super().save(*args, **kwargs)
class Meta:
abstract = True
......
......@@ -172,6 +172,24 @@ class PrimaryGroupField(ChoicePreference):
return Person.syncable_fields_choices()
@site_preferences_registry.register
class AutoCreatePerson(BooleanPreference):
section = account
name = "auto_create_person"
default = False
required = False
verbose_name = _("Automatically create new persons for new users")
@site_preferences_registry.register
class AutoLinkPerson(BooleanPreference):
section = account
name = "auto_link_person"
default = False
required = False
verbose_name = _("Automatically link existing persons to new users by their e-mail address")
@site_preferences_registry.register
class SchoolName(StringPreference):
section = school
......@@ -190,18 +208,6 @@ class SchoolNameOfficial(StringPreference):
verbose_name = _("Official name of the school, e.g. as given by supervisory authority")
@site_preferences_registry.register
class AuthenticationBackends(MultipleChoicePreference):
section = auth
name = "backends"
default = None
verbose_name = _("Enabled custom authentication backends")
field_attribute = {"initial": []}
def get_choices(self):
return [(b, b) for b in settings.CUSTOM_AUTHENTICATION_BACKENDS]
@site_preferences_registry.register
class AvailableLanguages(MultipleChoicePreference):
section = internationalisation
......
......@@ -42,6 +42,7 @@ DEBUG_TOOLBAR_CONFIG = {
"SHOW_COLLAPSED": True,
"JQUERY_URL": "",
"SHOW_TOOLBAR_CALLBACK": "aleksis.core.util.core_helpers.dt_show_toolbar",
"DISABLE_PANELS": {},
}
DEBUG_TOOLBAR_PANELS = [
......@@ -723,6 +724,4 @@ HEALTH_CHECK = {
"MEMORY_MIN": _settings.get("health.memory_min_mb", 500),
}
ORIGINAL_AUTHENTICATION_BACKENDS = AUTHENTICATION_BACKENDS[:]
PROMETHEUS_EXPORT_MIGRATIONS = False
......@@ -58,6 +58,9 @@ $(document).ready(function () {
// Initialize select [MAT]
$('select').formSelect();
// Initialize dropdown [MAT]
$('.dropdown-trigger').dropdown();
// If JS is activated, the language form will be auto-submitted
$('.language-field select').change(function () {
......
<div class="chip {{ classes }}">
{% if img %}
<img class="{{ img_classes }}" src="{{ img }}" alt="{{ alt }}">
{% endif %}
{{ content }}
{% if close %}
<i class="close material-icons"></i>
{% endif %}
</div>
......@@ -10,13 +10,20 @@
{% block content %}
{% for ct, model in widget_types %}
<a class="btn green waves-effect waves-light" href="{% url 'create_dashboard_widget' ct.app_label ct.model %}">
<i class="material-icons left">add</i>
{% verbose_name_object model as widget_name %}
{% blocktrans with name=widget_name %}Create {{ name }}{% endblocktrans %}
</a>
{% endfor %}
<a class="btn green waves-effect waves-light dropdown-trigger" href="#" data-target="widget-dropdown">
<i class="material-icons left">add</i>
{% trans "Create dashboard widget" %}
</a>
<ul id="widget-dropdown" class="dropdown-content">
{% for ct, model in widget_types %}
<li>
<a href="{% url 'create_dashboard_widget' ct.app_label ct.model %}">
{% verbose_name_object model as widget_name %}
{% blocktrans with name=widget_name %}Create {{ name }}{% endblocktrans %}
</a>
</li>
{% endfor %}
</ul>
{% has_perm "core.edit_default_dashboard" user as can_edit_default_dashboard %}
{% if can_edit_default_dashboard %}
......
from typing import Optional
from django.contrib.auth import authenticate
from django.contrib.auth.backends import BaseBackend
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import User
import pytest
from aleksis.core.apps import CoreConfig
from aleksis.core.util.core_helpers import get_site_preferences
pytestmark = pytest.mark.django_db
class DummyBackend(BaseBackend):
def authenticate(
self, request, username: str, password: str, **kwargs
) -> Optional[AbstractBaseUser]:
if username == "foo" and password == "baz":
return User.objects.get_or_create(username="foo")[0]
backend_name = "aleksis.core.tests.test_authentication_backends.DummyBackend"
def test_backends_simple(settings):
assert not authenticate(username="foo", password="baz")
assert backend_name not in settings.AUTHENTICATION_BACKENDS
settings.AUTHENTICATION_BACKENDS.append(backend_name)
assert backend_name in settings.AUTHENTICATION_BACKENDS
assert authenticate(username="foo", password="baz")
settings.AUTHENTICATION_BACKENDS.remove(backend_name)
assert not authenticate(username="foo", password="baz")
def test_backends_with_activation(settings):
assert not authenticate(username="foo", password="baz")
settings.CUSTOM_AUTHENTICATION_BACKENDS.append(backend_name)
assert backend_name not in get_site_preferences()["auth__backends"]
assert backend_name not in settings.AUTHENTICATION_BACKENDS
assert not authenticate(username="foo", password="baz")
print(get_site_preferences()["auth__backends"])
print(get_site_preferences()["auth__backends"].append(backend_name))
get_site_preferences()["auth__backends"] = [backend_name]
assert backend_name in get_site_preferences()["auth__backends"]
assert backend_name in settings.AUTHENTICATION_BACKENDS
assert authenticate(username="foo", password="baz")
get_site_preferences()["auth__backends"] = []
assert backend_name not in get_site_preferences()["auth__backends"]
assert backend_name not in settings.AUTHENTICATION_BACKENDS
......@@ -176,6 +176,9 @@ def has_person(obj: Union[HttpRequest, Model]) -> bool:
else:
return False
if obj.is_anonymous:
return False
person = getattr(obj, "person", None)
if person is None:
return False
......
......@@ -2,8 +2,8 @@ from typing import Callable
from django.http import HttpRequest, HttpResponse
from ..models import DummyPerson
from .core_helpers import has_person
from ..models import DummyPerson, Person
from .core_helpers import get_site_preferences, has_person
class EnsurePersonMiddleware:
......@@ -12,19 +12,43 @@ class EnsurePersonMiddleware:
It is needed to inject a dummy person to a superuser that would otherwise
not have an associated person, in order they can get their account set up
without external help.
In addition, if configured in preferences, it auto-creates or links persons
to regular users if they match.
"""
def __init__(self, get_response: Callable):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
if not has_person(request):
if request.user.is_superuser:
# Super-users get a dummy person linked
dummy_person = DummyPerson(
first_name=request.user.first_name, last_name=request.user.last_name
)
request.user.person = dummy_person
if not has_person(request) and not request.user.is_anonymous:
prefs = get_site_preferences()
if (
prefs.get("account__auto_link_person", False)
and request.user.first_name
and request.user.last_name
):
if prefs.get("account__auto_create_person"):
person, created = Person.objects.get_or_create(
email=request.user.email,
defaults={
"first_name": request.user.first_name,
"last_name": request.user.last_name,
},
)
person.user = request.user
else:
person = Person.objects.filter(email=request.user.email).first()
if person:
person.user = request.user
person.save()
if request.user.is_superuser and not has_person(request):
# Super-users get a dummy person linked
dummy_person = DummyPerson(
first_name=request.user.first_name, last_name=request.user.last_name
)
request.user.person = dummy_person
response = self.get_response(request)
return response
This diff is collapsed.
......@@ -38,7 +38,7 @@ Django = "^3.0"
django-any-js = "^1.0"
django-debug-toolbar = "^2.0"
django-middleware-global-request = "^0.1.2"
django-menu-generator = "^1.0.4"
django-menu-generator-ng = "^1.2.0"
django-tables2 = "^2.1"
Pillow = "^8.0"
django-phonenumber-field = {version = "<5.1", extras = ["phonenumbers"]}
......
......@@ -35,6 +35,7 @@ commands =
[testenv:build]
commands_pre =
poetry run sh -c "cd aleksis; aleksis-admin compilemessages"
commands = poetry build
[testenv:docs]
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment