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 (55)
Showing
with 598 additions and 388 deletions
......@@ -6,6 +6,61 @@ All notable changes to this project will be documented in this file.
The format is based on `Keep a Changelog`_,
and this project adheres to `Semantic Versioning`_.
Unreleased
----------
Added
~~~~~
* Allow apps to dynamically generate OAuth scopes
Fixed
~~~~~
* The view for getting the progress of celery tasks didn't respect that there can be anonymous users.
* Updated django to latest 3.2.x
Removed
~~~~~~~
* `OAUTH2_SCOPES` setting in apps is not supported anymore. Use `get_all_scopes` method
on `AppConfig` class instead.
`2.0rc4`_ - 2021-08-01
----------------------
Added
~~~~~
* Allow to configure port for prometheus metrics endpoint.
Fixed
~~~~~
* Correctly deliver server errors to user
* Use text HTTP response for serviceworker.js insteas of binary stream
* Use Django permission instead of rule to prevent performance issues.
`2.0rc3`_ - 2021-07-26
----------------------
Added
~~~~~
* Support PDF generation without available request object (started completely from background).
* Display a loading animation while fetching search results in the sidebar.
Fixed
~~~~~
* Make search suggestions selectable using the arrow keys.
Fixed
~~~~~
* Use correct HTML 5 elements for the search frontend and fix CSS accordingly.
`2.0rc2`_ - 2021-06-24
---------------------
......@@ -291,3 +346,4 @@ Fixed
.. _2.0b2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0b2
.. _2.0rc1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc1
.. _2.0rc2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc2
.. _2.0rc3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc3
......@@ -2,6 +2,7 @@ from typing import Any, Optional
import django.apps
from django.apps import apps
from django.conf import settings
from django.http import HttpRequest
from django.utils.module_loading import autodiscover_modules
......@@ -136,3 +137,18 @@ class CoreConfig(AppConfig):
if has_person(user):
# Save the associated person to pick up defaults
user.person.save()
def get_all_scopes(self) -> dict[str, str]:
scopes = {
"read": "Read anything the resource owner can read",
"write": "Write anything the resource owner can write",
}
if settings.OAUTH2_PROVIDER.get("OIDC_ENABLED", False):
scopes |= {
"openid": _("OpenID Connect scope"),
"profile": _("Given name, family name, link to profile and picture if existing."),
"address": _("Full home postal address"),
"email": _("Email address"),
"phone": _("Home and mobile phone"),
}
return scopes
# Generated by Django 3.2.4 on 2021-07-24 13:14
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('core', '0019_fix_uniqueness_per_site'),
]
operations = [
migrations.AlterField(
model_name='pdffile',
name='person',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pdf_files', to='core.person', verbose_name='Owner'),
),
]
......@@ -1037,7 +1037,12 @@ class PDFFile(ExtensibleModel):
return timezone.now() + timedelta(minutes=get_site_preferences()["general__pdf_expiration"])
person = models.ForeignKey(
to=Person, on_delete=models.CASCADE, verbose_name=_("Owner"), related_name="pdf_files"
to=Person,
on_delete=models.CASCADE,
blank=True,
null=True,
verbose_name=_("Owner"),
related_name="pdf_files",
)
expires_at = models.DateTimeField(
verbose_name=_("File expires at"), default=_get_default_expiration
......
......@@ -28,7 +28,7 @@ rules.add_perm("core.search_rule", search_predicate)
# View persons
view_persons_predicate = has_person & (
has_global_perm("core.view_person") | has_any_object("core.view_person_rule", Person)
has_global_perm("core.view_person") | has_any_object("core.view_person", Person)
)
rules.add_perm("core.view_persons_rule", view_persons_predicate)
......
import os
from glob import glob
from socket import getfqdn
from django.utils.translation import gettext_lazy as _
......@@ -66,7 +67,10 @@ UWSGI = {
UWSGI_SERVE_STATIC = True
UWSGI_SERVE_MEDIA = False
ALLOWED_HOSTS = _settings.get("http.allowed_hosts", [])
ALLOWED_HOSTS = _settings.get("http.allowed_hosts", [getfqdn(), "localhost", "127.0.0.1", "[::1]"])
BASE_URL = _settings.get(
"http.base_url", "http://localhost:8000" if DEBUG else f"https://{ALLOWED_HOSTS[0]}"
)
# Application definition
INSTALLED_APPS = [
......@@ -320,13 +324,7 @@ ACCOUNT_UNIQUE_EMAIL = _settings.get("auth.login.registration.unique_email", Tru
# Configuration for OAuth2 provider
OAUTH2_PROVIDER = {
"SCOPES": {
"read": "Read anything the resource owner can read",
"write": "Write anything the resource owner can write",
}
}
merge_app_settings("OAUTH2_SCOPES", OAUTH2_PROVIDER["SCOPES"], True)
OAUTH2_PROVIDER = {"SCOPES_BACKEND_CLASS": "aleksis.core.util.auth_helpers.AppScopes"}
if _settings.get("oauth2.oidc.enabled", False):
with open(_settings.get("oauth2.oidc.rsa_key", "/etc/aleksis/oidc.pem"), "r") as f:
......@@ -817,6 +815,8 @@ DBBACKUP_CHECK_SECONDS = _settings.get("backup.database.check_seconds", 7200)
MEDIABACKUP_CHECK_SECONDS = _settings.get("backup.media.check_seconds", 7200)
PROMETHEUS_EXPORT_MIGRATIONS = False
PROMETHEUS_METRICS_EXPORT_PORT = _settings.get("prometheus.metrics.port", None)
PROMETHEUS_METRICS_EXPORT_ADDRESS = _settings.get("prometheus.metrucs.address", None)
SECURE_PROXY_SSL_HEADER = ("REQUEST_SCHEME", "https")
......
......@@ -32,10 +32,11 @@ Autocomplete.prototype.setup = function () {
// Trigger the "keyup" event if input gets focused
this.query_box.focus(function () {
self.query_box.trigger("keydown");
self.query_box.trigger("input");
});
this.query_box.keyup(function () {
this.query_box.on("input", () => {
console.log("Input changed, fetching again...")
var query = self.query_box.val();
if (query.length < self.minimum_length) {
......@@ -97,11 +98,16 @@ Autocomplete.prototype.fetch = function (query) {
var self = this;
$.ajax({
url: this.url
, data: {
url: this.url,
data: {
'q': query
}
, success: function (data) {
},
beforeSend: (request, settings) => {
$('#search-results').remove();
self.setLoader(true);
},
success: function (data) {
self.setLoader(false);
self.show_results(data);
}
})
......@@ -122,3 +128,7 @@ Autocomplete.prototype.setSelectedResult = function (element) {
this.selected_element = element;
console.log("New element: ", element);
};
Autocomplete.prototype.setLoader = function (value) {
$("#search-loader").css("display", (value === true ? "block" : "none"))
}
......@@ -179,17 +179,25 @@ ul.sidenav li.logo > a:hover {
box-shadow: none;
}
.sidenav li.search .search-wrapper > i.material-icons {
.sidenav li.search .search-wrapper > button.search-button {
position: absolute;
top: 21px;
top: calc(50% - 18px);
right: 10px;
cursor: pointer;
}
button.btn-flat.search-button:hover {
background-color: $button-disabled-background;
}
a.collection-item.search-item {
padding: 20px 10px;
}
div#search-loader {
margin: 0.5rem 0 0 0;
display: none;
}
div#search-results {
position: absolute;
margin-top: -10px;
......
......@@ -6,7 +6,7 @@
<div class="container">
<div class="card red">
<div class="card-content white-text">
<div class="material-icons small left">error_outline</div>
<i class="material-icons small left">error_outline</i>
<span class="card-title">{% trans "Error" %} (500): {% blocktrans %}An unexpected error has
occured.{% endblocktrans %}</span>
<p>
......
......@@ -6,7 +6,7 @@
<div class="container">
<div class="card red">
<div class="card-content white-text">
<div class="material-icons small left">error_outline</div>
<i class="material-icons small left">error_outline</i>
<span class="card-title">{% blocktrans %}The maintenance mode is currently enabled. Please try again
later.{% endblocktrans %}</span>
<p>
......
......@@ -80,8 +80,11 @@
<li class="search">
<form method="get" action="{% url "haystack_search" %}" id="search-form" class="autocomplete">
<div class="search-wrapper">
<input id="search" name="q" placeholder="{% trans "Search" %}">
<i class="material-icons">search</i>
<input id="search" name="q" type="search" enterkeyhint="search" placeholder="{% trans "Search" %}">
<button class="btn btn-flat search-button" type="submit" aria-label="{% trans "Search" %}">
<i class="material-icons">search</i>
</button>
<div class="progress" id="search-loader"><div class="indeterminate"></div></div>
</div>
</form>
</li>
......
......@@ -226,6 +226,9 @@ urlpatterns = [
path("pdfs/<int:pk>/", views.RedirectToPDFFile.as_view(), name="redirect_to_pdf_file"),
]
# Use custom server error handler to get a request object in the template
handler500 = views.server_error
# Add URLs for optional features
if hasattr(settings, "TWILIO_ACCOUNT_SID"):
from two_factor.gateways.twilio.urls import urlpatterns as tf_twilio_urls # noqa
......
from importlib import metadata
from typing import Any, Optional, Sequence
from typing import TYPE_CHECKING, Any, Optional, Sequence
import django.apps
from django.contrib.auth.signals import user_logged_in, user_logged_out
......@@ -12,6 +12,9 @@ from spdx_license_list import LICENSES
from .core_helpers import copyright_years
if TYPE_CHECKING:
from oauth2_provider.models import AbstractApplication
class AppConfig(django.apps.AppConfig):
"""An extended version of DJango's AppConfig container."""
......@@ -214,6 +217,30 @@ class AppConfig(django.apps.AppConfig):
"""
pass
def get_all_scopes(self) -> dict[str, str]:
"""Return all OAuth scopes and their descriptions for this app."""
return {}
def get_available_scopes(
self,
application: Optional["AbstractApplication"] = None,
request: Optional[HttpRequest] = None,
*args,
**kwargs,
) -> list[str]:
"""Return a list of all OAuth scopes available to the request and application."""
return list(self.get_all_scopes().keys())
def get_default_scopes(
self,
application: Optional["AbstractApplication"] = None,
request: Optional[HttpRequest] = None,
*args,
**kwargs,
) -> list[str]:
"""Return a list of all OAuth scopes to always include for this request and application."""
return []
def _maintain_default_data(self):
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
......
"""Helpers/overrides for django-allauth."""
from typing import Optional
from django.conf import settings
from django.http import HttpRequest
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from oauth2_provider.models import AbstractApplication
from oauth2_provider.oauth2_validators import OAuth2Validator
from oauth2_provider.scopes import BaseScopes
from .apps import AppConfig
from .core_helpers import get_site_preferences, has_person
......@@ -73,3 +78,41 @@ class CustomOAuth2Validator(OAuth2Validator):
}
return claims
class AppScopes(BaseScopes):
"""Scopes backend for django-oauth-toolkit gathering scopes from apps.
Will call the respective method on all known AlekSIS app configs and
join the results.
"""
def get_all_scopes(self) -> dict[str, str]:
scopes = {}
for app in AppConfig.__subclasses__():
scopes |= app.get_all_scopes()
return scopes
def get_available_scopes(
self,
application: Optional[AbstractApplication] = None,
request: Optional[HttpRequest] = None,
*args,
**kwargs
) -> list[str]:
scopes = []
for app in AppConfig.__subclasses__():
scopes += app.get_available_scopes()
return scopes
def get_default_scopes(
self,
application: Optional[AbstractApplication] = None,
request: Optional[HttpRequest] = None,
*args,
**kwargs
) -> list[str]:
scopes = []
for app in AppConfig.__subclasses__():
scopes += app.get_default_scopes()
return scopes
import os
import subprocess # noqa
from tempfile import TemporaryDirectory
from typing import Optional
from typing import Optional, Tuple
from urllib.parse import urljoin
from django.conf import settings
from django.core.files import File
from django.core.files.base import ContentFile
from django.http.request import HttpRequest
......@@ -14,6 +16,7 @@ from django.utils import timezone
from django.utils.translation import get_language
from django.utils.translation import gettext as _
from celery.result import AsyncResult
from celery_progress.backend import ProgressRecorder
from aleksis.core.celery import app
......@@ -64,6 +67,26 @@ def generate_pdf(
recorder.set_progress(1, 1)
def generate_pdf_from_template(
template_name: str, context: Optional[dict] = None, request: Optional[HttpRequest] = None
) -> Tuple[PDFFile, AsyncResult]:
"""Start a PDF generation task and return the matching file object and Celery result."""
html_template = render_to_string(template_name, context, request)
file_object = PDFFile.objects.create(html_file=ContentFile(html_template, name="source.html"))
# As this method may be run in background and there is no request available,
# we have to use a predefined URL from settings then
if request:
html_url = request.build_absolute_uri(file_object.html_file.url)
else:
html_url = urljoin(settings.BASE_URL, file_object.html_file.url)
result = generate_pdf.delay(file_object.pk, html_url, lang=get_language())
return file_object, result
def render_pdf(request: HttpRequest, template_name: str, context: dict = None) -> HttpResponse:
"""Start PDF generation and show progress page.
......@@ -72,14 +95,7 @@ def render_pdf(request: HttpRequest, template_name: str, context: dict = None) -
if not context:
context = {}
html_template = render_to_string(template_name, context, request)
file_object = PDFFile.objects.create(
person=request.user.person, html_file=ContentFile(html_template, name="source.html")
)
html_url = request.build_absolute_uri(file_object.html_file.url)
result = generate_pdf.delay(file_object.pk, html_url, lang=get_language())
file_object, result = generate_pdf_from_template(template_name, context, request)
redirect_url = reverse("redirect_to_pdf_file", args=[file_object.pk])
......
......@@ -75,14 +75,19 @@ def has_any_object(perm: str, klass):
Build predicate which checks whether a user has access
to objects with the provided permission or rule.
Differentiates between object-related permissions and rules.
"""
name = f"has_any_object:{perm}"
@predicate(name)
def fn(user: User) -> bool:
ct_perm = get_content_type_by_perm(perm)
# In case an object-related permission with the same ContentType class as the given class
# is passed, the optimized django-guardian get_objects_for_user function is used.
if ct_perm and ct_perm.model_class() == klass:
return get_objects_for_user(user, perm, klass).exists()
# In other cases, it is checked for each object of the given model whether the current user
# fulfills the given rule.
else:
return queryset_rules_filter(user, klass.objects.all(), perm).exists()
......
......@@ -13,15 +13,17 @@ from django.http import (
HttpResponse,
HttpResponseNotFound,
HttpResponseRedirect,
HttpResponseServerError,
JsonResponse,
)
from django.http.response import FileResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template import loader
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.defaults import ERROR_500_TEMPLATE_NAME
from django.views.generic.base import TemplateView, View
from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.edit import DeleteView, UpdateView
......@@ -128,7 +130,9 @@ class ServiceWorkerView(View):
"""
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
return FileResponse(open(settings.SERVICE_WORKER_PATH))
return HttpResponse(
open(settings.SERVICE_WORKER_PATH, "rt"), content_type="application/javascript"
)
class ManifestView(View):
......@@ -1147,6 +1151,8 @@ class CeleryProgressView(View):
"""Wrap celery-progress view to check permissions before."""
def get(self, request: HttpRequest, task_id: str, *args, **kwargs) -> HttpResponse:
if request.user.is_anonymous:
raise Http404()
if not TaskUserAssignment.objects.filter(
task_result__task_id=task_id, user=request.user
).exists():
......@@ -1198,3 +1204,13 @@ class SocialAccountDeleteView(DeleteView):
self.request, _("The third-party account has been successfully disconnected.")
)
return HttpResponseRedirect(success_url)
def server_error(
request: HttpRequest, template_name: str = ERROR_500_TEMPLATE_NAME
) -> HttpResponseServerError:
"""Ensure the request is passed to the error page."""
template = loader.get_template(template_name)
context = {"request": request}
return HttpResponseServerError(template.render(context))
......@@ -47,6 +47,7 @@ Install some packages from the Debian package system.
python3 \
python3-dev \
libldap2-dev \
libpq-dev \
libsasl2-dev \
yarnpkg \
python3-virtualenv \
......
This diff is collapsed.
......@@ -47,7 +47,7 @@ secondary = true
[tool.poetry.dependencies]
python = "^3.9"
Django = "^3.2"
Django = "^3.2.5"
django-any-js = "^1.1"
django-debug-toolbar = "^3.2"
django-menu-generator-ng = "^1.2.3"
......@@ -82,7 +82,7 @@ django-celery-email = "^3.0.0"
django-jsonstore = "^0.5.0"
django-polymorphic = "^3.0.0"
django-colorfield = "^0.4.0"
django-bleach = "^0.6.1"
django-bleach = "^0.7.0"
django-guardian = "^2.2.0"
rules = "^2.2"
django-cache-memoize = "^0.1.6"
......@@ -91,7 +91,7 @@ celery-haystack-ng = "^0.20"
django-dbbackup = "^3.3.0"
spdx-license-list = "^0.5.0"
license-expression = "^1.2"
django-reversion = "^3.0.7"
django-reversion = "^4.0.0"
django-favicon-plus-reloaded = "^1.1.2"
django-health-check = "^3.12.1"
psutil = "^5.7.0"
......@@ -100,7 +100,7 @@ django-cachalot = "^2.3.2"
django-prometheus = "^2.1.0"
django-model-utils = "^4.0.0"
bs4 = "^0.0.1"
django-allauth = "^0.44.0"
django-allauth = "^0.45.0"
django-uwsgi-ng = "^1.1.0"
django-extensions = "^3.1.1"
ipython = "^7.20.0"
......