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 (42)
Showing
with 403 additions and 59 deletions
......@@ -9,12 +9,82 @@ and this project adheres to `Semantic Versioning`_.
Unreleased
----------
Added
~~~~~
* GraphQL schema for Rooms
* [Dev] UpdateIndicator Vue Component to display the status of interactive pages
Changed
~~~~~~~
* Show message on successful logout to inform users properly.
Fixed
~~~~~
* GraphQL endpoints for groups, persons, and notifications didn't expose all necessary fields.
* Loading indicator in toolbar was not shown at the complete loading progress.
* 404 page was sometimes shown while the page was still loading.
* Setting of page height in the iframe was not working correctly.
* App switched to offline state when the user was logged out/in.
* The `Stop Impersonation` button is not shown due to an oversee when changing the type of the whoAmI query to an object of UserType
`3.0b3`_ - 2023-03-19
---------------------
Fixed
~~~~~
* Some GraphQL queries could return more data than permitted in related fields.
`3.0b2`_ - 2023-03-09
---------------------
Changed
~~~~~~~
* Change default network policy of the Apollo client to `cache-and-network`.
Fixed
~~~~~
* In case the status code of a response was not in the range between 200 and 299
but still indicates that the response should be delivered, e. g. in the case
of a redirected request, the service worker served the offline fallback page.
* In some cases, the resize listener for the IFrame in the `LegacyBaseTemplate`
did not trigger.
* [Dev] Allow apps to declare URLs in the non-legacy namespace again
`3.0b1`_ - 2023-02-27
---------------------
Added
~~~~~
* Support for two factor authentication via email codes and Webauthn.
`3.0b0`_ - 2023-02-15
---------------------
This release starts a new era of the AlekSIS® framework, by introducing a
dynamic frontend app written in Vue.js which communicates with the backend
through GraphQL. Support for legacy views (Django templates and
Materialize) was removed; while there is backwards compatibility for now,
this is only used by official apps until their views are fully migrated.
AlekSIS and its new frontend require Node.js version 18 or higher to run the
Vite bundler. On Debian, this means that Debian 12 (bookworm) is needed, or
Node.js must be installed from a third-party repository.
Removed
~~~~~~~
* Official support for views rendered server-side in Django is removed. The
`LegacyBaseTemplate` provided for backwards compatibility must not be used
by apps declaring a dependency on AlekSIS >= 3.0.
* Support for deploying AlekSIS in sub-URLs
* Support for production deployments without HTTPS
Deprecated
~~~~~~~~~~
......@@ -26,17 +96,17 @@ Added
~~~~~
* Notification drawer in top nav bar
* GraphQL queries and mutations for core data management
* [Dev] Introduce new mechanism to register classes over all apps.
* Data template for `room` model used for haystack search indexing moved to core.
* Support for two factor authentication via email codes and Webauthn.
* GraphQL queries for base system and some core data management
* [Dev] New mechanism to register classes over all apps (RegistryObject)
* Model for rooms
Changed
~~~~~~~
* Show languages in local language
* Rewrite of frontend using Vuetify
* The runuwsgi dev server now starts a Vite dev server with HMR in the
* Rewrite of frontend (base template) using Vuetify
* Frontend bundling migrated from Webpack to Vite (cf. installation docs)
* [Dev] The runuwsgi dev server now starts a Vite dev server with HMR in the
background
* OIDC scope "profile" now exposes the avatar instead of the official photo
* Based on Django 4.0
......@@ -55,26 +125,23 @@ Changed
Fixed
~~~~~
* The error page displayed when an ObjectOverview component is not able to get the required data was incomplete.
* In some cases, the IFrame for legacy pages was not properly sized for its content.
* When accessing the person overview page without a person ID, the avatar image was not displayed properly.
* The system tried to send notifications for done background tasks
in addition to tasks started in the foreground.
* 2FA via messages or phone calls didn't work.
in addition to tasks started in the foreground
* 2FA via messages or phone calls didn't work after a faulty dependency
update
* [Dev] Site reference on extensible models can no longer cause name clashes
because of its related name.
because of its related name
Removed
~~~~~~~
* Support for materialize-based frontend views (deprecated in 2.11)
* Legacy support for person iCal feed URLs.
* Django debug toolbar
* iCal feed URLs for birthdays (will be reintroduced later)
* [Dev] Django debug toolbar
* It caused major performance issues and is not useful with the new
frontend anymore
`2.12.3` - 2023-03-07
---------------------
`2.12.3`_ - 2023-03-07
----------------------
Fixed
~~~~~
......@@ -1048,3 +1115,7 @@ Fixed
.. _2.12.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.1
.. _2.12.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.2
.. _2.12.3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.3
.. _3.0b0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b0
.. _3.0b1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b1
.. _3.0b2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b2
.. _3.0b3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b3
......@@ -2,7 +2,7 @@ FROM debian:bookworm-slim AS core
# Build arguments
ARG EXTRAS="ldap,s3,sentry"
ARG APP_VERSION="==2.10.1.dev0+20220801181456.7ba74939"
ARG APP_VERSION="==3.0b0"
# Configure Python to be nice inside Docker and pip to stfu
ENV PYTHONUNBUFFERED 1
......@@ -64,6 +64,7 @@ RUN set -e; \
${ALEKSIS_static__root} \
${ALEKSIS_media__root} \
${ALEKSIS_backup__location}; \
dpkg-divert --rename --add /usr/lib/$(py3versions -d)/EXTERNALLY-MANAGED; \
eatmydata pip install AlekSIS-Core\[$EXTRAS\]$APP_VERSION
# Define entrypoint, volumes and uWSGI running on port 8000
......
......@@ -3,6 +3,7 @@ from typing import Any, Optional
import django.apps
from django.apps import apps
from django.conf import settings
from django.contrib import messages
from django.http import HttpRequest
from django.utils.module_loading import autodiscover_modules
from django.utils.translation import gettext as _
......@@ -144,6 +145,11 @@ class CoreConfig(AppConfig):
# Save the associated person to pick up defaults
user.person.save()
def user_logged_out(
self, sender: type, request: Optional[HttpRequest], user: "User", **kwargs
) -> None:
messages.success(request, _("You have been logged out successfully."))
@classmethod
def get_all_scopes(cls) -> dict[str, str]:
scopes = {
......
......@@ -70,7 +70,7 @@ const apolloOpts = {
}
// Add a snackbar on all errors returned by the GraphQL endpoint
// If App is offline, don't add snackbar since only the ping query is active
if (!vm.$root.offline) {
if (!vm.$root.offline && !vm.$root.invalidation) {
vm.$root.snackbarItems.push({
id: crypto.randomUUID(),
timeout: 5000,
......@@ -79,7 +79,7 @@ const apolloOpts = {
});
}
}
if (networkError) {
if (networkError && !vm.$root.invalidation) {
// Set app offline globally on network errors
// This will cause the offline logic to kick in, starting a ping check or
// similar recovery strategies depending on the app/navigator state
......@@ -90,6 +90,7 @@ const apolloOpts = {
vm.$root.offline = true;
}
},
fetchPolicy: "cache-and-network",
},
},
};
......
......@@ -77,13 +77,24 @@ export default {
const title = this.$refs.contentIFrame.contentWindow.document.title;
this.$root.$setPageTitle(title);
// Adapt height of IFrame according to the height of its contents once and listen to resize events
this.iFrameHeight =
this.$refs.contentIFrame.contentDocument.body.scrollHeight;
this.$refs.contentIFrame.contentWindow.onresize = () => {
// Adapt height of IFrame according to the height of its contents once and observe height changes
if (
this.$refs.contentIFrame.contentDocument &&
this.$refs.contentIFrame.contentDocument.body
) {
this.iFrameHeight =
this.$refs.contentIFrame.contentDocument.body.scrollHeight;
};
new ResizeObserver(() => {
if (
this.$refs.contentIFrame &&
this.$refs.contentIFrame.contentDocument &&
this.$refs.contentIFrame.contentDocument.body
) {
this.iFrameHeight =
this.$refs.contentIFrame.contentDocument.body.scrollHeight;
}
}).observe(this.$refs.contentIFrame.contentDocument.body);
}
this.$root.contentLoading = false;
},
......
......@@ -45,7 +45,10 @@
>
<v-icon>mdi-update</v-icon>
</v-btn>
<div v-if="whoAmI && whoAmI.isAuthenticated" class="d-flex">
<div
v-if="whoAmI && whoAmI.isAuthenticated && whoAmI.person"
class="d-flex"
>
<notification-list v-if="!whoAmI.person.isDummy" />
<account-menu
:account-menu="accountMenu"
......@@ -83,7 +86,7 @@
</div>
<error-page
v-if="error404"
v-if="error404 && !$root.contentLoading"
short-error-message-key="network_errors.error_404"
long-error-message-key="network_errors.page_not_found"
redirect-button-text-key="network_errors.back_to_start"
......@@ -97,6 +100,7 @@
checkPermission($route.meta.permission) ||
$route.name === 'dashboard'
"
@mounted="routeComponentMounted"
/>
<error-page
v-else-if="
......@@ -253,6 +257,13 @@ export default {
pollInterval: 1000,
},
},
methods: {
routeComponentMounted() {
if (!this.$root.isLegacyBaseTemplate) {
this.$root.contentLoading = false;
}
},
},
watch: {
systemProperties: function (newProperties) {
this.$vuetify.theme.themes.light.primary =
......@@ -272,7 +283,7 @@ export default {
},
$route: {
handler(newRoute) {
if (newRoute.matched.length == 0) {
if (newRoute.matched.length === 0) {
this.error404 = true;
} else {
this.error404 = false;
......
......@@ -3,6 +3,7 @@ query ($permissions: [String]!) {
username
isAuthenticated
isAnonymous
isImpersonate
person {
photo {
url
......@@ -10,7 +11,6 @@ query ($permissions: [String]!) {
fullName
avatarUrl
isDummy
isImpersonate
}
permissions: globalPermissionsByName(permissions: $permissions) {
name
......
<template>
<v-tooltip bottom>
<template #activator="{ on, attrs }">
<v-btn
right
icon
v-bind="attrs"
v-on="on"
@click="handleClick"
:loading="status === $options.UPDATING"
>
<v-icon v-if="status !== $options.UPDATING" :color="color">
{{ icon }}
</v-icon>
</v-btn>
</template>
<span>{{ text }}</span>
</v-tooltip>
</template>
<script>
export default {
ERROR: "ERROR", // Something went wrong
SAVED: "SAVED", // Everything alright
UPDATING: "UPDATING", // We are sending something to the server
CHANGES: "CHANGES", // the user changed something, but it has not been saved yet
name: "UpdateIndicator",
emits: ["manual-update"],
props: {
status: {
type: String,
required: true,
},
},
computed: {
text() {
switch (this.status) {
case this.$options.SAVED:
return this.$t("status.saved");
case this.$options.UPDATING:
return this.$t("status.updating");
case this.$options.CHANGES:
return this.$t("status.changes");
default:
return this.$t("status.error");
}
},
color() {
switch (this.status) {
case this.$options.SAVED:
return "success";
case this.$options.CHANGES:
return "secondary";
case this.$options.UPDATING:
return "secondary";
default:
return "error";
}
},
icon() {
switch (this.status) {
case this.$options.SAVED:
return "$success";
case this.$options.CHANGES:
return "mdi-dots-horizontal";
default:
return "$warning";
}
},
isAbleToClick() {
return (
this.status === this.$options.CHANGES ||
this.status === this.$options.ERROR
);
},
},
methods: {
handleClick() {
if (this.isAbleToClick) {
this.$emit("manual-update");
}
},
},
};
</script>
......@@ -65,11 +65,25 @@ const app = new Vue({
render: (h) => h(App),
data: () => ({
showCacheAlert: false,
contentLoading: false,
contentLoading: true,
offline: false,
backgroundActive: true,
invalidation: false,
snackbarItems: [],
}),
computed: {
matchedComponents() {
if (this.$route.matched.length > 0) {
return this.$route.matched.map(
(route) => route.components.default.name
);
}
return [];
},
isLegacyBaseTemplate() {
return this.matchedComponents.includes("LegacyBaseTemplate");
},
},
router,
i18n,
});
......
......@@ -224,5 +224,11 @@
},
"graphql": {
"snackbar_error_message": "There was an error retrieving the page data. Please try again."
},
"status": {
"saved": "All changes are saved.",
"updating": "Changes are being synced.",
"changes": "You have unsaved changes.",
"error": "There has been an error while saving the latest changes."
}
}
......@@ -20,7 +20,7 @@ const aleksisMixin = {
},
},
mounted() {
this.$root.contentLoading = false;
this.$emit("mounted");
},
beforeDestroy() {
// Unregister all safely added event listeners as to not leak them
......
......@@ -123,15 +123,19 @@ AleksisVue.install = function (Vue) {
Vue.prototype.$invalidateState = function () {
console.info("Invalidating application state");
this.invalidation = true;
this.$apollo
.getClient()
.resetStore()
.then(
function () {
() => {
console.info("GraphQL cache cleared");
this.invalidation = false;
},
function (error) {
(error) => {
console.error("Could not clear GraphQL cache:", error);
this.invalidation = false;
}
);
};
......@@ -156,6 +160,12 @@ AleksisVue.install = function (Vue) {
// eslint-disable-next-line no-unused-vars
this.$router.afterEach((to, from) => {
if (vm.isLegacyBaseTemplate) {
// Skip resetting loading state for legacy pages
// as they are probably not finished with loading yet
// LegacyBaseTemplate will reset the loading state later
return;
}
vm.contentLoading = false;
});
......
......@@ -60,49 +60,49 @@ rules.add_perm("core.view_persons_rule", view_persons_predicate)
# View person
view_person_predicate = has_person & (
has_global_perm("core.view_person") | has_object_perm("core.view_person") | is_current_person
is_current_person | has_global_perm("core.view_person") | has_object_perm("core.view_person")
)
rules.add_perm("core.view_person_rule", view_person_predicate)
# View person address
view_address_predicate = has_person & (
has_global_perm("core.view_address") | has_object_perm("core.view_address") | is_current_person
is_current_person | has_global_perm("core.view_address") | has_object_perm("core.view_address")
)
rules.add_perm("core.view_address_rule", view_address_predicate)
# View person contact details
view_contact_details_predicate = has_person & (
has_global_perm("core.view_contact_details")
is_current_person
| has_global_perm("core.view_contact_details")
| has_object_perm("core.view_contact_details")
| is_current_person
)
rules.add_perm("core.view_contact_details_rule", view_contact_details_predicate)
# View person photo
view_photo_predicate = has_person & (
has_global_perm("core.view_photo") | has_object_perm("core.view_photo") | is_current_person
is_current_person | has_global_perm("core.view_photo") | has_object_perm("core.view_photo")
)
rules.add_perm("core.view_photo_rule", view_photo_predicate)
# View person avatar image
view_avatar_predicate = has_person & (
has_global_perm("core.view_avatar") | has_object_perm("core.view_avatar") | is_current_person
is_current_person | has_global_perm("core.view_avatar") | has_object_perm("core.view_avatar")
)
rules.add_perm("core.view_avatar_rule", view_avatar_predicate)
# View persons groups
view_groups_predicate = has_person & (
has_global_perm("core.view_person_groups")
is_current_person
| has_global_perm("core.view_person_groups")
| has_object_perm("core.view_person_groups")
| is_current_person
)
rules.add_perm("core.view_person_groups_rule", view_groups_predicate)
# Edit person
edit_person_predicate = has_person & (
has_global_perm("core.change_person")
is_current_person & is_site_preference_set("account", "editable_fields_person")
| has_global_perm("core.change_person")
| has_object_perm("core.change_person")
| is_current_person & is_site_preference_set("account", "editable_fields_person")
)
rules.add_perm("core.edit_person_rule", edit_person_predicate)
......@@ -191,9 +191,9 @@ rules.add_perm(
# View person personal details
view_personal_details_predicate = has_person & (
has_global_perm("core.view_personal_details")
is_current_person
| has_global_perm("core.view_personal_details")
| has_object_perm("core.view_personal_details")
| is_current_person
)
rules.add_perm("core.view_personal_details_rule", view_personal_details_predicate)
......@@ -206,9 +206,9 @@ rules.add_perm("core.change_site_preferences_rule", change_site_preferences)
# Change person preferences
change_person_preferences = has_person & (
has_global_perm("core.change_person_preferences")
is_current_person
| has_global_perm("core.change_person_preferences")
| has_object_perm("core.change_person_preferences")
| is_current_person
)
rules.add_perm("core.change_person_preferences_rule", change_person_preferences)
......@@ -251,6 +251,12 @@ view_additional_fields_predicate = has_person & (
)
rules.add_perm("core.view_additionalfields_rule", view_additional_fields_predicate)
# View group type
view_group_type_predicate = has_person & (
has_global_perm("core.view_grouptype") | has_object_perm("core.view_grouptype")
)
rules.add_perm("core.view_grouptype_rule", view_group_type_predicate)
# Edit group type
change_group_type_predicate = has_person & (
has_global_perm("core.change_grouptype") | has_object_perm("core.change_grouptype")
......
from django.core.exceptions import PermissionDenied
from graphene_django import DjangoObjectType
from guardian.shortcuts import get_objects_for_user
from ..models import Group
from ..models import Group, Person
from ..util.core_helpers import has_person
class GroupType(DjangoObjectType):
class Meta:
model = Group
fields = [
"id",
"school_term",
"name",
"short_name",
"members",
"owners",
"parent_groups",
"group_type",
"additional_fields",
"photo",
"avatar",
]
@staticmethod
def resolve_parent_groups(root, info, **kwargs):
return get_objects_for_user(info.context.user, "core.view_group", root.parent_groups.all())
@staticmethod
def resolve_members(root, info, **kwargs):
persons = get_objects_for_user(info.context.user, "core.view_person", root.members.all())
if has_person(info.context.user) and [
m for m in root.members.all() if m.pk == info.context.user.person.pk
]:
persons = (persons | Person.objects.get(pk=info.context.user.person.pk)).distinct()
return persons
@staticmethod
def resolve_owners(root, info, **kwargs):
persons = get_objects_for_user(info.context.user, "core.view_person", root.owners.all())
if has_person(info.context.user) and [
o for o in root.owners.all() if o.pk == info.context.user.person.pk
]:
persons = (persons | Person.objects.get(pk=info.context.user.person.pk)).distinct()
return persons
@staticmethod
def resolve_group_type(root, info, **kwargs):
if info.context.user.has_perm("core.view_grouptype_rule", root.group_type):
return root.group_type
raise PermissionDenied()
@staticmethod
def resolve_additional_fields(root, info, **kwargs):
return get_objects_for_user(
info.context.user, "core.view_additionalfield", root.additional_fields.all()
)
......@@ -9,6 +9,26 @@ from ..models import Notification
class NotificationType(DjangoObjectType):
class Meta:
model = Notification
fields = [
"id",
"sender",
"recipient",
"title",
"description",
"link",
"icon",
"send_at",
"read",
"sent",
"created",
"modified",
]
@staticmethod
def resolve_recipient(root, info, **kwargs):
if info.context.user.has_perm("core.view_person_rule", root.recipient):
return root.recipient
raise PermissionDenied()
class MarkNotificationReadMutation(graphene.Mutation):
......
from django.core.exceptions import PermissionDenied
import graphene
from graphene_django import DjangoObjectType
......@@ -11,3 +13,9 @@ class PDFFileType(DjangoObjectType):
class Meta:
model = PDFFile
exclude = ["html_file"]
@staticmethod
def resolve_person(root, info, **kwargs):
if info.context.user.has_perm("core.view_person_rule", root.person):
return root.person
raise PermissionDenied()
......@@ -6,10 +6,11 @@ from django.utils import timezone
import graphene
from graphene_django import DjangoObjectType
from graphene_django.forms.mutation import DjangoModelFormMutation
from guardian.shortcuts import get_objects_for_user
from ..forms import PersonForm
from ..models import DummyPerson, Person
from ..util.core_helpers import get_site_preferences, is_impersonate
from ..util.core_helpers import get_site_preferences, has_person
from .base import FieldFileType
from .notification import NotificationType
......@@ -24,6 +25,32 @@ class PersonPreferencesType(graphene.ObjectType):
class PersonType(DjangoObjectType):
class Meta:
model = Person
fields = [
"id",
"user",
"first_name",
"last_name",
"additional_name",
"short_name",
"street",
"housenumber",
"postal_code",
"place",
"phone_number",
"mobile_number",
"email",
"date_of_birth",
"place_of_birth",
"sex",
"photo",
"avatar",
"guardians",
"primary_group",
"description",
"children",
"owner_of",
"member_of",
]
full_name = graphene.String()
username = graphene.String()
......@@ -38,7 +65,6 @@ class PersonType(DjangoObjectType):
unread_notifications_count = graphene.Int()
is_dummy = graphene.Boolean()
is_impersonate = graphene.Boolean()
preferences = graphene.Field(PersonPreferencesType)
can_edit_person = graphene.Boolean()
......@@ -94,24 +120,29 @@ class PersonType(DjangoObjectType):
def resolve_children(root, info, **kwargs): # noqa
if info.context.user.has_perm("core.view_personal_details_rule", root):
return root.children.all()
return get_objects_for_user(info.context.user, "core.view_person", root.children.all())
return []
def resolve_guardians(root, info, **kwargs): # noqa
if info.context.user.has_perm("core.view_personal_details_rule", root):
return root.guardians.all()
return get_objects_for_user(info.context.user, "core.view_person", root.guardians.all())
return []
def resolve_member_of(root, info, **kwargs): # noqa
if info.context.user.has_perm("core.view_person_groups_rule", root):
return root.member_of.all()
return get_objects_for_user(info.context.user, "core.view_group", root.member_of.all())
return []
def resolve_owner_of(root, info, **kwargs): # noqa
if info.context.user.has_perm("core.view_person_groups_rule", root):
return root.owner_of.all()
return get_objects_for_user(info.context.user, "core.view_group", root.owner_of.all())
return []
def resolve_primary_group(root, info, **kwargs): # noqa
if info.context.user.has_perm("core.view_group_rule", root.primary_group):
return root.primary_group
raise PermissionDenied()
def resolve_username(root, info, **kwargs): # noqa
return root.user.username if root.user else None
......@@ -167,11 +198,12 @@ class PersonType(DjangoObjectType):
def resolve_is_dummy(root: Union[Person, DummyPerson], info, **kwargs):
return root.is_dummy if hasattr(root, "is_dummy") else False
def resolve_is_impersonate(root: Person, info, **kwargs):
return is_impersonate(info.context)
def resolve_notifications(root: Person, info, **kwargs):
return root.notifications.filter(send_at__lte=timezone.now()).order_by("read", "-created")
if has_person(info.context.user) and info.context.user.person == root:
return root.notifications.filter(send_at__lte=timezone.now()).order_by(
"read", "-created"
)
raise PermissionDenied()
def resolve_can_edit_person(root, info, **kwargs): # noqa
return info.context.user.has_perm("core.edit_person_rule", root)
......
from graphene_django import DjangoObjectType
from ..models import Room
class RoomType(DjangoObjectType):
class Meta:
model = Room
fields = ("id", "name", "short_name")
......@@ -12,6 +12,7 @@ class UserType(graphene.ObjectType):
is_authenticated = graphene.Boolean(required=True)
is_anonymous = graphene.Boolean(required=True)
is_impersonate = graphene.Boolean()
person = graphene.Field(PersonType)
......
......@@ -68,7 +68,8 @@ DJANGO_VITE_DEV_SERVER_PORT = DEV_SERVER_PORT + 1
ALLOWED_HOSTS = _settings.get("http.allowed_hosts", [getfqdn(), "localhost", "127.0.0.1", "[::1]"])
BASE_URL = _settings.get(
"http.base_url", f"http://localhost:{DEV_SERVER_PORT}" if DEBUG else f"//{ALLOWED_HOSTS[0]}"
"http.base_url",
f"http://localhost:{DEV_SERVER_PORT}" if DEBUG else f"https://{ALLOWED_HOSTS[0]}",
)
......