Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • hansegucker/AlekSIS-Core
  • pinguin/AlekSIS-Core
  • AlekSIS/official/AlekSIS-Core
  • sunweaver/AlekSIS-Core
  • sggua/AlekSIS-Core
  • edward/AlekSIS-Core
  • magicfelix/AlekSIS-Core
7 results
Show changes
Commits on Source (37)
......@@ -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]
......