diff --git a/src/core/models/events.py b/src/core/models/events.py index 252470f0f7592d33f9f4a294e2a7107bb5e049e5..695781807e7628d1e83f8753d7fa9ae2d96af629 100644 --- a/src/core/models/events.py +++ b/src/core/models/events.py @@ -6,7 +6,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.db.models import Q -from django.urls import reverse, NoReverseMatch +from django.urls import NoReverseMatch from django.utils import timezone from django.utils.dateparse import parse_datetime from django.utils.text import slugify @@ -53,12 +53,15 @@ class EventManager(models.Manager): return qs.filter(is_public=True) def conference_accessible(self, conference: Conference): - return self.get_queryset().filter(conference=conference, is_public=True) \ + return ( + self.get_queryset() + .filter(conference=conference, is_public=True) .filter( Q(assembly__state_assembly__in=Assembly.PUBLIC_STATES) | Q(assembly__state_channel__in=Assembly.PUBLIC_STATES) | Q(kind=Event.Kind.SELF_ORGANIZED) ) + ) def manageable_by_user(self, user: PlatformUser, conference: Conference): assert user is not None @@ -83,106 +86,136 @@ class EventManager(models.Manager): pass # for everybody else, only show public events - manageable_assemblies_qs = Assembly.objects.manageable_by_user(conference=conference, user=user) \ - .filter(members__member=user, members__can_manage_assembly=True) - return qs.filter(Q(kind=Event.Kind.SELF_ORGANIZED, owner=user) | Q(assembly_id__in=manageable_assemblies_qs.values_list('id', flat=True))) + manageable_assemblies_qs = Assembly.objects.manageable_by_user( + conference=conference, user=user + ).filter(members__member=user, members__can_manage_assembly=True) + return qs.filter( + Q(kind=Event.Kind.SELF_ORGANIZED, owner=user) + | Q(assembly_id__in=manageable_assemblies_qs.values_list("id", flat=True)) + ) class Event(TaggedItemMixin, BackendMixin, models.Model): class Meta: constraints = [ - models.constraints.UniqueConstraint(fields=['conference', 'slug'], name='unique_event_slug'), - models.constraints.CheckConstraint(check=Q(kind='sos') | Q(assembly__isnull=False), name='event_has_assembly_or_is_sos'), + models.constraints.UniqueConstraint( + fields=["conference", "slug"], name="unique_event_slug" + ), + models.constraints.CheckConstraint( + check=Q(kind="sos") | Q(assembly__isnull=False), + name="event_has_assembly_or_is_sos", + ), ] - verbose_name = _('Event') - verbose_name_plural = _('Events') + verbose_name = _("Event") + verbose_name_plural = _("Events") class Kind(models.TextChoices): - OFFICIAL = 'official', _('Event__kind-official') # offizielles Event - ASSEMBLY = 'assembly', _('Event__kind-assembly') # von einer Assembly kuratiert - SELF_ORGANIZED = 'sos', _('Event__kind-sos') # hat Einzelperson angelegt + OFFICIAL = "official", _("Event__kind-official") # offizielles Event + ASSEMBLY = "assembly", _("Event__kind-assembly") # von einer Assembly kuratiert + SELF_ORGANIZED = "sos", _("Event__kind-sos") # hat Einzelperson angelegt objects = EventManager() id = models.UUIDField(default=uuid4, primary_key=True, editable=False) - conference = ConferenceReference(related_name='events') + conference = ConferenceReference(related_name="events") slug = models.SlugField( - max_length=150, - help_text=_('Event__slug__help'), - verbose_name=_('Event__slug')) + max_length=150, help_text=_("Event__slug__help"), verbose_name=_("Event__slug") + ) kind = models.CharField( max_length=10, choices=Kind.choices, default=Kind.ASSEMBLY, - help_text=_('Event__kind__help'), - verbose_name=_('Event__kind')) + help_text=_("Event__kind__help"), + verbose_name=_("Event__kind"), + ) - name = models.CharField( - max_length=200) - track = models.ForeignKey(ConferenceTrack, blank=True, null=True, on_delete=models.PROTECT) - assembly = models.ForeignKey(Assembly, related_name='events', on_delete=models.PROTECT, blank=True, null=True) - room = models.ForeignKey(Room, blank=True, null=True, related_name='events', on_delete=models.PROTECT) + name = models.CharField(max_length=200) + track = models.ForeignKey( + ConferenceTrack, blank=True, null=True, on_delete=models.PROTECT + ) + assembly = models.ForeignKey( + Assembly, related_name="events", on_delete=models.PROTECT, blank=True, null=True + ) + room = models.ForeignKey( + Room, blank=True, null=True, related_name="events", on_delete=models.PROTECT + ) language = models.CharField( - max_length=50, blank=True, null=True, - help_text=_('Event__language__help'), - verbose_name=_('Event__language')) + max_length=50, + blank=True, + null=True, + help_text=_("Event__language__help"), + verbose_name=_("Event__language"), + ) abstract = models.TextField( blank=True, - help_text=_('Event__abstract__help'), - verbose_name=_('Event__abstract')) + help_text=_("Event__abstract__help"), + verbose_name=_("Event__abstract"), + ) description = models.TextField( blank=True, - help_text=_('Event__description__help'), - verbose_name=_('Event__description')) + help_text=_("Event__description__help"), + verbose_name=_("Event__description"), + ) description_html = models.TextField(blank=True) banner_image_height = models.PositiveIntegerField(blank=True, null=True) banner_image_width = models.PositiveIntegerField(blank=True, null=True) banner_image = models.ImageField( - blank=True, null=True, - height_field='banner_image_height', - width_field='banner_image_width', - help_text=_('Event__banner_image__help'), - verbose_name=_('Event__banner_image')) + blank=True, + null=True, + height_field="banner_image_height", + width_field="banner_image_width", + help_text=_("Event__banner_image__help"), + verbose_name=_("Event__banner_image"), + ) is_public = models.BooleanField( default=False, - help_text=_('Event__is_public__help'), - verbose_name=_('Event__is_public')) + help_text=_("Event__is_public__help"), + verbose_name=_("Event__is_public"), + ) schedule_start = models.DateTimeField( - blank=True, null=True, - help_text=_('Event__schedule_start__help'), - verbose_name=_('Event__schedule_start')) + blank=True, + null=True, + help_text=_("Event__schedule_start__help"), + verbose_name=_("Event__schedule_start"), + ) schedule_duration = models.DurationField( - blank=True, null=True, - help_text=_('Event__schedule_duration__help'), - verbose_name=_('Event__schedule_duration')) + blank=True, + null=True, + help_text=_("Event__schedule_duration__help"), + verbose_name=_("Event__schedule_duration"), + ) schedule_end = models.DateTimeField( - blank=True, null=True, - help_text=_('Event__schedule_end__help'), - verbose_name=_('Event__schedule_end')) + blank=True, + null=True, + help_text=_("Event__schedule_end__help"), + verbose_name=_("Event__schedule_end"), + ) additional_data = models.JSONField(blank=True, null=True) favorited_by = models.ManyToManyField( PlatformUser, - related_name='favorite_events', - help_text=_('Event__favorited_by__help'), - verbose_name=_('Event__favorited_by') + related_name="favorite_events", + help_text=_("Event__favorited_by__help"), + verbose_name=_("Event__favorited_by"), ) in_personal_calendar = models.ManyToManyField( PlatformUser, - related_name='calendar_events', - help_text=_('Event__in_personal_calendar__help'), - verbose_name=_('Event__in_personal_calendar') + related_name="calendar_events", + help_text=_("Event__in_personal_calendar__help"), + verbose_name=_("Event__in_personal_calendar"), ) owner = models.ForeignKey( settings.AUTH_USER_MODEL, - blank=True, null=True, - related_name='owned_events', + blank=True, + null=True, + related_name="owned_events", on_delete=models.PROTECT, - help_text=_('Event__owner__help'), - verbose_name=_('Event__owner')) + help_text=_("Event__owner__help"), + verbose_name=_("Event__owner"), + ) """ Ersteller/Eigentümer des Events, dies ist insb. für Self-Organized-Sessions relevant. """ @@ -190,8 +223,12 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): last_update = models.DateTimeField(auto_now=True) def get_absolute_url(self): + from core.templatetags.hub_absolute import hub_absolute + try: - local_url = reverse('plainui:event', kwargs={'event_slug': self.slug}) + local_url = hub_absolute( + "plainui:event", event_slug=self.slug, i18n=settings.ARCHIVE_MODE + ) prefix = settings.FORCE_SCRIPT_NAME or settings.SCRIPT_NAME if prefix is not None and local_url.startswith(prefix): local_url = local_url[len(prefix):] @@ -204,7 +241,9 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): @property def public_speakers(self): """Returns a list of all public speakers of this event.""" - return self.participants.objects.filter(role=EventParticipant.Role.SPEAKER, is_accepted=True, is_public=True).select_related('participant') + return self.participants.objects.filter( + role=EventParticipant.Role.SPEAKER, is_accepted=True, is_public=True + ).select_related("participant") def get_all_speaker_names(self): # plainui will prefetch speaker names in self.speakers => only do db lookup if we don't have that @@ -212,11 +251,15 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): names = OrderedSet(self.speakers) except AttributeError: names = OrderedSet() - names |= OrderedSet(self.participants.filter(is_public=True, role=EventParticipant.Role.SPEAKER).values_list('participant__username', flat=True)) + names |= OrderedSet( + self.participants.filter( + is_public=True, role=EventParticipant.Role.SPEAKER + ).values_list("participant__username", flat=True) + ) if self.additional_data is not None: - for x in self.additional_data.get('persons', []): - names.add(x.get('public_name')) + for x in self.additional_data.get("persons", []): + names.add(x.get("public_name")) if self.kind == Event.Kind.SELF_ORGANIZED and self.owner: names.add(self.owner.username) @@ -224,11 +267,11 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): return names def get_url(self, domain=None): - if domain == 'media.ccc.de': - return f'https://media.ccc.de/v/{self.id}' + if domain == "media.ccc.de": + return f"https://media.ccc.de/v/{self.id}" if self.additional_data is not None: - return self.additional_data.get('url') + return self.additional_data.get("url") return None @@ -249,9 +292,17 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): return self.name @classmethod - def from_dict(cls, data: dict, conference: Conference, assembly: Assembly = None, existing=None, pop_used_keys: bool = False, - allow_kind: bool = False, allow_track: bool = False, - room_lookup=None): + def from_dict( + cls, + data: dict, + conference: Conference, + assembly: Assembly = None, + existing=None, + pop_used_keys: bool = False, + allow_kind: bool = False, + allow_track: bool = False, + room_lookup=None, + ): """ Loads an Event instance from the given dictionary. An existing event can be provided which's data is overwritten (in parts). @@ -268,18 +319,26 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): :returns: a new event or the existing one with fields updated from the given data :rtype: Event """ - assert isinstance(data, dict), 'Data must be a dictionary.' - assert existing is None or isinstance(existing, cls), 'Existing instance must be an Event.' + assert isinstance(data, dict), "Data must be a dictionary." + assert existing is None or isinstance( + existing, cls + ), "Existing instance must be an Event." - given_uuid = UUID(data.pop('guid')) if 'guid' in data else None + given_uuid = UUID(data.pop("guid")) if "guid" in data else None if existing: obj = existing if assembly: - assert obj.assembly == assembly, 'Existing event\'s assembly does not match given one.' + assert ( + obj.assembly == assembly + ), "Existing event's assembly does not match given one." if conference: - assert obj.conference == conference, 'Existing event\'s conference does not match given one.' + assert ( + obj.conference == conference + ), "Existing event's conference does not match given one." if given_uuid is not None: - assert obj.pk == given_uuid, f'expected existing event\'s id {obj.pk} to match the given uuid {given_uuid}' + assert ( + obj.pk == given_uuid + ), f"expected existing event's id {obj.pk} to match the given uuid {given_uuid}" else: obj = cls(assembly=assembly, conference=conference) if given_uuid is not None: @@ -287,14 +346,17 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): assert obj.conference_id is not None direct_fields = [ - 'slug', 'name', - 'abstract', 'description', - 'language', - 'is_public', - 'schedule_start', 'schedule_end', + "slug", + "name", + "abstract", + "description", + "language", + "is_public", + "schedule_start", + "schedule_end", ] - bool_fields = ['is_public'] - dt_fields = ['schedule_start', 'schedule_end'] + bool_fields = ["is_public"] + dt_fields = ["schedule_start", "schedule_end"] for fld in direct_fields: if fld in data: value = data[fld] if not pop_used_keys else data.pop(fld) @@ -308,39 +370,43 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): if pop_used_keys: del data[fld] - if 'schedule_duration' in data: - value = data['schedule_duration'] - obj.schedule_duration = str2timedelta(value) if not isinstance(value, timedelta) else value + if "schedule_duration" in data: + value = data["schedule_duration"] + obj.schedule_duration = ( + str2timedelta(value) if not isinstance(value, timedelta) else value + ) if pop_used_keys: - del data['schedule_duration'] + del data["schedule_duration"] - if 'kind' in data: + if "kind" in data: if allow_kind: - obj.kind = data['kind'] + obj.kind = data["kind"] if pop_used_keys: - del data['kind'] + del data["kind"] - if 'track' in data: + if "track" in data: if allow_track: - track_q = Q(slug__iexact=data['track']) | Q(name__iexact=data['track']) + track_q = Q(slug__iexact=data["track"]) | Q(name__iexact=data["track"]) trk = assembly.conference.tracks.get(track_q) obj.track = trk if pop_used_keys: - del data['track'] + del data["track"] - if 'room' in data: + if "room" in data: if room_lookup is not None: - room = room_lookup(data['room']) + room = room_lookup(data["room"]) obj.room = room else: - raise RuntimeWarning('Event.from_dict() got data with "room" but no room_lookup was provided.') + raise RuntimeWarning( + 'Event.from_dict() got data with "room" but no room_lookup was provided.' + ) if pop_used_keys: - del data['room'] + del data["room"] if pop_used_keys: obj.additional_data = data - elif 'additional_data' in data: - obj.additional_data = data['additional_data'] + elif "additional_data" in data: + obj.additional_data = data["additional_data"] obj.clean() return obj @@ -352,47 +418,73 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): # ensure track is of the same conference if self.track_id is not None and self.track.conference_id != self.conference_id: - errors['track'] = _('Event__track__needsameconference') + errors["track"] = _("Event__track__needsameconference") # ensure assembly is in the same conference if self.assembly_id and self.assembly.conference_id != self.conference_id: - errors['assembly'] = _('Event__assembly__needssameconference') + errors["assembly"] = _("Event__assembly__needssameconference") # ensure room belongs to ... if self.room_id is not None: # ... the same conference if self.room.conference_id != self.conference_id: - errors['room'] = errors.get('room', []).append(_('Event__room__needssameconference')) + errors["room"] = errors.get("room", []).append( + _("Event__room__needssameconference") + ) # ... the same assembly if self.assembly_id is None: self.assembly = self.room.assembly elif self.room.assembly != self.assembly: - errors['room'] = errors.get('room', []).append(_('Event__room__needssameassembly')) + errors["room"] = errors.get("room", []).append( + _("Event__room__needssameassembly") + ) # official events only in an official assembly if self.kind == Event.Kind.OFFICIAL and not self.assembly.is_official: - errors['kind'] = _('Event__kind__officalassemblyonly') + errors["kind"] = _("Event__kind__officalassemblyonly") # start must be within conference timeframe if self.schedule_start is not None: - if (self.conference.start is not None and self.conference.start > self.schedule_start) or \ - (self.conference.end is not None and self.conference.end < self.schedule_start): - errors['schedule_start'] = _('Event__schedule_start__outside_conference') + if ( + self.conference.start is not None + and self.conference.start > self.schedule_start + ) or ( + self.conference.end is not None + and self.conference.end < self.schedule_start + ): + errors["schedule_start"] = _( + "Event__schedule_start__outside_conference" + ) if self.schedule_duration is not None: # duration must not be zero - if self.schedule_duration == '' or self.schedule_duration == 0: - errors['schedule_duration'] = _('Event__schedule_duration__must_not_be_zero') + if self.schedule_duration == "" or self.schedule_duration == 0: + errors["schedule_duration"] = _( + "Event__schedule_duration__must_not_be_zero" + ) # duration cannot be longer than the whole conference - elif self.conference is not None and self.conference.end is not None and self.schedule_duration > (self.conference.end - self.conference.start): - errors['schedule_duration'] = _('Event__schedule_duration__longer_than_conference') + elif ( + self.conference is not None + and self.conference.end is not None + and self.schedule_duration + > (self.conference.end - self.conference.start) + ): + errors["schedule_duration"] = _( + "Event__schedule_duration__longer_than_conference" + ) # end cannot be after the end of the conference - if self.conference is not None and self.conference.end is not None and self.schedule_start is not None and \ - (self.schedule_start + self.schedule_duration) > self.conference.end: - errors['schedule_duration'] = errors.get('schedule_duration') or _('Event__schedule_end__outside_conference') + if ( + self.conference is not None + and self.conference.end is not None + and self.schedule_start is not None + and (self.schedule_start + self.schedule_duration) > self.conference.end + ): + errors["schedule_duration"] = errors.get("schedule_duration") or _( + "Event__schedule_end__outside_conference" + ) if errors: raise ValidationError(errors) @@ -407,11 +499,26 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): if not self.slug: self.slug = gen_slug = slugify(self.name)[:50] - while Event.objects.filter(conference=self.conference, slug=self.slug).exclude(pk=self.pk).exists(): - self.slug = gen_slug[:45] + '_' + ''.join([random.SystemRandom().choice('abcdefghijklmnopqrstuvwxyz0123456789') for i in range(4)]) - - if update_fields is None or 'description' in update_fields: - compile_translated_markdown_fields(self, 'description') + while ( + Event.objects.filter(conference=self.conference, slug=self.slug) + .exclude(pk=self.pk) + .exists() + ): + self.slug = ( + gen_slug[:45] + + "_" + + "".join( + [ + random.SystemRandom().choice( + "abcdefghijklmnopqrstuvwxyz0123456789" + ) + for i in range(4) + ] + ) + ) + + if update_fields is None or "description" in update_fields: + compile_translated_markdown_fields(self, "description") return super().save(*args, update_fields=update_fields, **kwargs) @@ -431,19 +538,19 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): def _EventAttachment_upload_path(instance, filename): # file will be uploaded to MEDIA_ROOT/user_<id>/<filename> - return 'eventattachment/{0}/{1}'.format(instance.event.id, instance.id) + return "eventattachment/{0}/{1}".format(instance.event.id, instance.id) class EventAttachment(models.Model): class Meta: - verbose_name = _('EventAttachment') - verbose_name_plural = _('EventAttachments') + verbose_name = _("EventAttachment") + verbose_name_plural = _("EventAttachments") class Visibility(models.TextChoices): - PRIVATE = 'private', _('EventAttachment__visibility-private') - PARTICIPANTS = 'participants', _('EventAttachment__visibility-participants') - CONFERENCE = 'conference', _('EventAttachment__visibility-conference') - PUBLIC = 'public', _('EventAttachment__visibility-public') + PRIVATE = "private", _("EventAttachment__visibility-private") + PARTICIPANTS = "participants", _("EventAttachment__visibility-participants") + CONFERENCE = "conference", _("EventAttachment__visibility-conference") + PUBLIC = "public", _("EventAttachment__visibility-public") event = models.ForeignKey(Event, on_delete=models.CASCADE) id = models.UUIDField(default=uuid4, primary_key=True, editable=False) @@ -452,65 +559,83 @@ class EventAttachment(models.Model): filename = models.CharField(max_length=255) mime_type = models.CharField(max_length=100) - visibility = models.CharField(max_length=20, choices=Visibility.choices, default=Visibility.PRIVATE) + visibility = models.CharField( + max_length=20, choices=Visibility.choices, default=Visibility.PRIVATE + ) class EventParticipant(models.Model): class Role(models.TextChoices): - SPEAKER = 'speaker', _('EventParticipant__type-speaker') - ANGEL = 'angel', _('EventParticipant__type-angel') - REGULAR = 'regular', _('EventParticipant__type-regular') - PROSPECT = 'prospect', _('EventParticipant__type-prospect') + SPEAKER = "speaker", _("EventParticipant__type-speaker") + ANGEL = "angel", _("EventParticipant__type-angel") + REGULAR = "regular", _("EventParticipant__type-regular") + PROSPECT = "prospect", _("EventParticipant__type-prospect") id = models.UUIDField(default=uuid4, primary_key=True, editable=False) - event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='participants') - participant = models.ForeignKey(PlatformUser, on_delete=models.CASCADE, related_name='events') + event = models.ForeignKey( + Event, on_delete=models.CASCADE, related_name="participants" + ) + participant = models.ForeignKey( + PlatformUser, on_delete=models.CASCADE, related_name="events" + ) role = models.CharField( - max_length=20, choices=Role.choices, default=Role.REGULAR, - help_text=_('EventParticipant__role__help'), - verbose_name=_('EventParticipant__role')) + max_length=20, + choices=Role.choices, + default=Role.REGULAR, + help_text=_("EventParticipant__role__help"), + verbose_name=_("EventParticipant__role"), + ) is_accepted = models.BooleanField( default=False, - help_text=_('EventParticipant__is_accepted__help'), - verbose_name=_('EventParticipant__is_accepted')) + help_text=_("EventParticipant__is_accepted__help"), + verbose_name=_("EventParticipant__is_accepted"), + ) is_public = models.BooleanField( default=False, - help_text=_('EventParticipant__is_public__help'), - verbose_name=_('EventParticipant__is_public')) + help_text=_("EventParticipant__is_public__help"), + verbose_name=_("EventParticipant__is_public"), + ) public_description = models.TextField( - blank=True, null=True, - help_text=_('EventParticipant__public_description__help'), - verbose_name=_('EventParticipant__public_description')) + blank=True, + null=True, + help_text=_("EventParticipant__public_description__help"), + verbose_name=_("EventParticipant__public_description"), + ) personal_comment = models.TextField( - blank=True, null=True, - help_text=_('EventParticipant__personal_comment__help'), - verbose_name=_('EventParticipant__personal_comment')) + blank=True, + null=True, + help_text=_("EventParticipant__personal_comment__help"), + verbose_name=_("EventParticipant__personal_comment"), + ) def clean(self, *args, **kwargs): # verify that the participant is a member of the event's conference try: - ConferenceMember.objects.get(conference=self.event.conference, user=self.participant) + ConferenceMember.objects.get( + conference=self.event.conference, user=self.participant + ) except ConferenceMember.DoesNotExist: - raise ValidationError(_('EventParticipant__must_be_conference_member')) + raise ValidationError(_("EventParticipant__must_be_conference_member")) def __str__(self): result = self.participant.username if self.role in [self.Role.SPEAKER, self.Role.ANGEL]: - result += ' ' + self.get_role_display() + result += " " + self.get_role_display() return result class EventLikeCount(models.Model): class Meta: - indexes = [ - models.Index(fields=['event1', 'like_ratio']) - ] - unique_together = [('event1', 'event2')] - event1 = models.ForeignKey(Event, related_name='suggestions', on_delete=models.CASCADE) - event2 = models.ForeignKey(Event, related_name='+', on_delete=models.CASCADE) + indexes = [models.Index(fields=["event1", "like_ratio"])] + unique_together = [("event1", "event2")] + + event1 = models.ForeignKey( + Event, related_name="suggestions", on_delete=models.CASCADE + ) + event2 = models.ForeignKey(Event, related_name="+", on_delete=models.CASCADE) likes = models.IntegerField() like_ratio = models.IntegerField() diff --git a/src/core/templatetags/hub_absolute.py b/src/core/templatetags/hub_absolute.py new file mode 100644 index 0000000000000000000000000000000000000000..f3e169970e7732da7c12442eb155742832d2eb08 --- /dev/null +++ b/src/core/templatetags/hub_absolute.py @@ -0,0 +1,99 @@ +from typing import Optional + +from django.conf import settings +from django.conf.urls.i18n import i18n_patterns +from django.contrib import admin +from django.http import HttpRequest +from django.template.defaulttags import register +from django.urls import ( + NoReverseMatch, + get_script_prefix, + include, + path, + reverse, + set_script_prefix, +) +from django.utils import translation + +PREFIXES = { + "plainui": settings.PLAINUI_BASE_URL, +} + + +class PlainUIUrlPatterns: + def __init__(self) -> None: + self.urlpatterns = i18n_patterns( + path("", include("plainui.urls", namespace="plainui")), + prefix_default_language=True, + ) + + +class UrlPatternWrapper: + def __init__(self, namespace, urls) -> None: + self.urlpatterns = [path("", include(urls, namespace=namespace))] + + +URL_CONFIGS = { + "plain": { + "admin": admin.site.urls, + "api": UrlPatternWrapper("api", "api.urls"), + "backoffice": UrlPatternWrapper("backoffice", "backoffice.urls"), + "plainui": UrlPatternWrapper("plainui", "plainui.urls"), + }, + "i18n": { + "admin": admin.site.urls, + "api": UrlPatternWrapper("api", "api.urls"), + "backoffice": UrlPatternWrapper("backoffice", "backoffice.urls"), + "plainui": PlainUIUrlPatterns(), + }, +} + + +@register.simple_tag +def hub_absolute( + url: str, + *args, + i18n: bool = True, + lang: Optional[str] = None, + query_string: str = "", + **kwargs, +): + try: + app, _ = url.split(":") + except ValueError: + raise NoReverseMatch("No reverse found, missing namespace") + thread_prefix = get_script_prefix() + set_script_prefix(PREFIXES[app]) + cur_language = translation.get_language() + try: + if lang: + # switch language if requested + translation.activate(lang) + reverse_url = reverse( + url, + URL_CONFIGS["i18n" if i18n else "plain"][app], + current_app=app, + args=args, + kwargs=kwargs, + ) + finally: + translation.activate(cur_language) + set_script_prefix(thread_prefix) + return reverse_url if not query_string else f"{reverse_url}?{query_string}" + + +@register.simple_tag +def hub_absolute_self( + request: HttpRequest, i18n: bool = True, lang: Optional[str] = None +): + url = f"{request.resolver_match.app_name}:{request.resolver_match.url_name}" + query_string = request.META.get("QUERY_STRING") + + return hub_absolute( + url, + i18n=i18n, + lang=lang, + query_string=query_string, + *request.resolver_match.args, + **request.resolver_match.kwargs, + ) diff --git a/src/hub/settings/base.py b/src/hub/settings/base.py index 2e7ae67748aa09d6bdbfea89d1967a07524a35cc..821b3b7c4ae0804c9602c26345546934ab9664ee 100644 --- a/src/hub/settings/base.py +++ b/src/hub/settings/base.py @@ -20,56 +20,60 @@ from django.utils.translation import gettext_lazy as _ import environ -SCRIPT_NAME = os.getenv('SCRIPT_NAME', '') +SCRIPT_NAME = os.getenv("SCRIPT_NAME", "") env = environ.FileAwareEnv( # set casting, default value - DJANGO_ENV_FILE=(str, ''), - DJANGO_DEBUG=(str, ''), - LOG_LEVEL=(str, 'WARN'), - ALLOWED_HOSTS=(list, ['127.0.0.1', 'localhost']), - MAIL_FROM=(str, 'webmaster+mail_from_not_configured@localhost'), - STATIC_URL=(str, f'{SCRIPT_NAME}/static/'), - STORAGE_TYPE=(str, ''), - MEDIA_URL=(str, f'{SCRIPT_NAME}/media/'), + DJANGO_ENV_FILE=(str, ""), + DJANGO_DEBUG=(str, ""), + LOG_LEVEL=(str, "WARN"), + ALLOWED_HOSTS=(list, ["127.0.0.1", "localhost"]), + MAIL_FROM=(str, "webmaster+mail_from_not_configured@localhost"), + STATIC_URL=(str, f"{SCRIPT_NAME}/static/"), + STORAGE_TYPE=(str, ""), + MEDIA_URL=(str, f"{SCRIPT_NAME}/media/"), REDIS_URL=(str, None), - COOKIE_NAME=(str, 'HUB_SESSION'), - COOKIE_PATH=(str, SCRIPT_NAME), # defaults to empty string, '/' will be used below in this case anyhow + COOKIE_NAME=(str, "HUB_SESSION"), + COOKIE_PATH=( + str, + SCRIPT_NAME, + ), # defaults to empty string, '/' will be used below in this case anyhow CLIENT_IP_HEADER=(str, None), DISABLE_RATELIMIT=(bool, False), - SENTRY_ENDPOINT=(str, None), - SENTRY_TRACES_SAMPLE_RATE=(float, 0.05), # create a trace for this percentage of all requests - SENTRY_PROFILES_SAMPLE_RATE=(float, 0.50), # do a profiling for this percentage of _traced_ requests - + SENTRY_TRACES_SAMPLE_RATE=( + float, + 0.05, + ), # create a trace for this percentage of all requests + SENTRY_PROFILES_SAMPLE_RATE=( + float, + 0.50, + ), # do a profiling for this percentage of _traced_ requests SSO_SECRET=(str, None), SSO_SECRET_GENERATE=(bool, False), - PRETIX_ISSUER=(str, 'tickets.events.ccc.de'), + PRETIX_ISSUER=(str, "tickets.events.ccc.de"), PRETIX_SECRET=(str, None), - - METRICS_SERVER_IPS=(list, ['*']), - TIMEZONE=(str, 'Europe/Berlin'), - + METRICS_SERVER_IPS=(list, ["*"]), + TIMEZONE=(str, "Europe/Berlin"), + ARCHIVE_MODE=(bool, False), BIGBLUEBUTTON=(bool, False), BIGBLUEBUTTON_API_URL=(str, None), BIGBLUEBUTTON_API_TOKEN=(str, None), BIGBLUEBUTTON_END_MEETING_CALLBACK=(str, None), BIGBLUEBUTTON_INITIAL_PRESENTATION_URL=(str, None), - HANGAR=(bool, False), HANGAR_URL=(str, None), - WORKADVENTURE=(bool, False), - WORKADVENTURE_URL_SCHEME_GENERAL=(str, 'http://visit.at.localhost:8080/'), + WORKADVENTURE_URL_SCHEME_GENERAL=(str, "http://visit.at.localhost:8080/"), WORKADVENTURE_URL_SCHEME_ASSEMBLY=( str, - 'http://visit.at.localhost:8080/as/{assembly_slug}', + "http://visit.at.localhost:8080/as/{assembly_slug}", ), WORKADVENTURE_URL_SCHEME_REGISTER=( str, - 'http://visit.at.localhost:8080/register/{token}', + "http://visit.at.localhost:8080/register/{token}", ), - WORKADVENTURE_CORS_ORIGIN=(str, 'https://visit.at.localhost'), + WORKADVENTURE_CORS_ORIGIN=(str, "https://visit.at.localhost"), WORKADVENTURE_BACKEND_MAP_PUSH_URL=(str, None), WORKADVENTURE_BACKEND_MAP_PUSH_AUTHORIZATION=(str, None), WORKADVENTURE_BACKEND_USERINFO_PUSH_URL=(str, None), @@ -83,14 +87,12 @@ env = environ.FileAwareEnv( WORKADVENTURE_MAPSERVICE_LOGLEVEL_URL=(str, None), WORKADVENTURE_MAPSERVICE_LOGLEVEL_AUTHORIZATION=(str, None), WORKADVENTURE_MAPSERVICE_LOGLEVEL_NOVERIFY=(bool, True), - - PLAINUI_BASE_URL=(str, ''), + PLAINUI_BASE_URL=(str, ""), PLAINUI_CONFERENCE=(str, None), - PLAINUI_DEREFERER_URL=(str, '//dereferrer/{quoted_target}'), + PLAINUI_DEREFERER_URL=(str, "//dereferrer/{quoted_target}"), PLAINUI_DEREFERER_ALLOWLIST=(list, []), PLAINUI_DEREFERER_COUNTLIST=(list, []), - - SHIBBOLEET_WA_ROOM_ID=('str', None), + SHIBBOLEET_WA_ROOM_ID=("str", None), ) @@ -98,38 +100,49 @@ def gen_secret_key(): """generates a value suitable for SECRET_KEY""" import random - SECRET_CHARSET = 'abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)' - return ''.join([random.SystemRandom().choice(SECRET_CHARSET) for i in range(50)]) + SECRET_CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)" + return "".join([random.SystemRandom().choice(SECRET_CHARSET) for i in range(50)]) # regex to match arbitrary hostnames -REGEX_HOST = re.compile(r'([\w\d-]+\.)+\w+(:\d+)?') +REGEX_HOST = re.compile(r"([\w\d-]+\.)+\w+(:\d+)?") # helper function for parsing DEREFERER_*LIST (allow hostname only, regex and https:// prefixed strings) def _handle_hostpattern_list(target: list, items: list): for item in items: - if item.startswith('/') and item.endswith('/'): + if item.startswith("/") and item.endswith("/"): try: - target.append(re.compile('https://' + item[1:-1])) + target.append(re.compile("https://" + item[1:-1])) except re.error as err: - print('WARNING: failed to parse regex pattern "', item, "', ignoring it: ", err, sep='') - elif item.startswith('https://') or item.startswith('http://'): + print( + 'WARNING: failed to parse regex pattern "', + item, + "', ignoring it: ", + err, + sep="", + ) + elif item.startswith("https://") or item.startswith("http://"): target.append(item) elif REGEX_HOST.fullmatch(item): - target.append('https://' + item) + target.append("https://" + item) else: - print('WARNING: failed to parse pattern "', item, "' (expected hostname, url or slash-enclosed regex), skipping it", sep='') + print( + 'WARNING: failed to parse pattern "', + item, + "' (expected hostname, url or slash-enclosed regex), skipping it", + sep="", + ) # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent -if env('DJANGO_ENV_FILE'): - DJANGO_ENV_FILE_PATH = Path(env('DJANGO_ENV_FILE')) +if env("DJANGO_ENV_FILE"): + DJANGO_ENV_FILE_PATH = Path(env("DJANGO_ENV_FILE")) assert ( DJANGO_ENV_FILE_PATH.exists() - ), f'DJANGO_ENV_FILE path {DJANGO_ENV_FILE_PATH} does not exist!' + ), f"DJANGO_ENV_FILE path {DJANGO_ENV_FILE_PATH} does not exist!" environ.Env.read_env(DJANGO_ENV_FILE_PATH) # prepare SECRET_KEY but must be overridden, i.e. in default.py @@ -137,92 +150,92 @@ if env('DJANGO_ENV_FILE'): SECRET_KEY = None # don't run with debug turned on in production by default -DEBUG = env('DJANGO_DEBUG') == 'I_KNOW_WHAT_I_AM_DOING' +DEBUG = env("DJANGO_DEBUG") == "I_KNOW_WHAT_I_AM_DOING" # allowed hosts may be multiple -ALLOWED_HOSTS = env('ALLOWED_HOSTS') +ALLOWED_HOSTS = env("ALLOWED_HOSTS") # read application version from marker file -with BASE_DIR.parent.joinpath('version.json').open() as version_file: +with BASE_DIR.parent.joinpath("version.json").open() as version_file: APP_VERSION_INFO = json.load(version_file) # Application definition INSTALLED_APPS = [ # Django integrated stuff - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.humanize', - 'django.contrib.postgres', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.gis', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.humanize", + "django.contrib.postgres", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.gis", # 3rd party extensions - 'modeltranslation', - 'oauth2_provider', - 'rest_framework', - 'rest_framework.authtoken', + "modeltranslation", + "oauth2_provider", + "rest_framework", + "rest_framework.authtoken", # our apps - 'core', - 'plainui', + "core", + "plainui", ] MIDDLEWARE = [ - 'core.middleware.SetRemoteAddrMiddleware', - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'oauth2_provider.middleware.OAuth2TokenMiddleware', - 'hub.middleware.conference.ConferenceLocaleMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'core.middleware.TimezoneMiddleware', + "core.middleware.SetRemoteAddrMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "oauth2_provider.middleware.OAuth2TokenMiddleware", + "hub.middleware.conference.ConferenceLocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "core.middleware.TimezoneMiddleware", # 'django.middleware.clickjacking.XFrameOptionsMiddleware', # TODO drüber nachdenken ob wir die brauchen (ist default an in Django) ] -ROOT_URLCONF = 'hub.urls' +ROOT_URLCONF = "hub.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - BASE_DIR / 'templates', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + BASE_DIR / "templates", ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'hub.wsgi.application' +WSGI_APPLICATION = "hub.wsgi.application" # Database # https://docs.djangoproject.com/en/3.1/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.contrib.gis.db.backends.postgis', - 'NAME': 'hub', + "default": { + "ENGINE": "django.contrib.gis.db.backends.postgis", + "NAME": "hub", } } # Custom User model -AUTH_USER_MODEL = 'core.PlatformUser' +AUTH_USER_MODEL = "core.PlatformUser" # default auto fields (legacy stuff for corsheaders app) -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # Password validation @@ -230,70 +243,82 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] AUTHENTICATION_BACKENDS = ( - 'oauth2_provider.backends.OAuth2Backend', - 'django.contrib.auth.backends.ModelBackend', + "oauth2_provider.backends.OAuth2Backend", + "django.contrib.auth.backends.ModelBackend", ) # Session Cookie configuration -SESSION_COOKIE_NAME = env('COOKIE_NAME') +SESSION_COOKIE_NAME = env("COOKIE_NAME") SESSION_COOKIE_HTTPONLY = True # session cookie is unavailable to JavaScript (default) -SESSION_COOKIE_SAMESITE = 'Lax' # set SameSite=Lax (default) -SESSION_COOKIE_PATH = env('COOKIE_PATH') or '/' # use configured path, SESSION_NAME or default '/' +SESSION_COOKIE_SAMESITE = "Lax" # set SameSite=Lax (default) +SESSION_COOKIE_PATH = ( + env("COOKIE_PATH") or "/" +) # use configured path, SESSION_NAME or default '/' SESSION_COOKIE_SECURE = True # mark session cookie as https-only SESSION_SAVE_EVERY_REQUEST = ( False # no need to update a session on each request (default) ) # CSRF Cookie configuration -CSRF_COOKIE_NAME = env('CSRF_COOKIE_NAME', default=SESSION_COOKIE_NAME.replace('_SESSION', '_CSRF') if '_SESSION' in SESSION_COOKIE_NAME else 'HUB_CSRF') +CSRF_COOKIE_NAME = env( + "CSRF_COOKIE_NAME", + default=SESSION_COOKIE_NAME.replace("_SESSION", "_CSRF") + if "_SESSION" in SESSION_COOKIE_NAME + else "HUB_CSRF", +) CSRF_COOKIE_PATH = SESSION_COOKIE_PATH CSRF_COOKIE_SECURE = SESSION_COOKIE_SECURE # OAuth2 configuration -OAUTH2_PROVIDER_APPLICATION_MODEL = 'core.Application' +OAUTH2_PROVIDER_APPLICATION_MODEL = "core.Application" OAUTH2_PROVIDER = { - 'ALLOWED_REDIRECT_URI_SCHEMES': ['https'], - 'SCOPES_BACKEND_CLASS': 'core.sso.OAuth2Scopes', + "ALLOWED_REDIRECT_URI_SCHEMES": ["https"], + "SCOPES_BACKEND_CLASS": "core.sso.OAuth2Scopes", } # Internationalization # https://docs.djangoproject.com/en/3.1/topics/i18n/ -LANGUAGE_CODE = 'en-us' -LANGUAGE_COOKIE_NAME = env('LANGUAGE_COOKIE_NAME', default=SESSION_COOKIE_NAME.replace('_SESSION', '_LANG') if '_SESSION' in SESSION_COOKIE_NAME else 'HUB_LANG') # noqa:E501 +LANGUAGE_CODE = "en-us" +LANGUAGE_COOKIE_NAME = env( + "LANGUAGE_COOKIE_NAME", + default=SESSION_COOKIE_NAME.replace("_SESSION", "_LANG") + if "_SESSION" in SESSION_COOKIE_NAME + else "HUB_LANG", +) # noqa:E501 LANGUAGE_COOKIE_PATH = SESSION_COOKIE_PATH LANGUAGE_COOKIE_SECURE = SESSION_COOKIE_SECURE -TIME_ZONE = env('TIMEZONE') +TIME_ZONE = env("TIMEZONE") USE_I18N = True USE_L10N = True USE_TZ = True LANGUAGES = [ - ('de', _("German")), - ('en', _("English")), + ("de", _("German")), + ("en", _("English")), ] -MODELTRANSLATION_FALLBACK_LANGUAGES = ['en', 'de'] +MODELTRANSLATION_FALLBACK_LANGUAGES = ["en", "de"] # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.1/howto/static-files/ -STATIC_URL = env('STATIC_URL') -STATIC_ROOT = BASE_DIR.parent / 'static.dist' +STATIC_URL = env("STATIC_URL") +STATIC_ROOT = BASE_DIR.parent / "static.dist" # STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' # STATICFILES_DIRS = [ # BASE_DIR / "static", @@ -306,84 +331,90 @@ STORAGES = { }, } -storage_type = env('STORAGE_TYPE').lower() -if storage_type == 's3': - STORAGES['default'] = {'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage'} +storage_type = env("STORAGE_TYPE").lower() +if storage_type == "s3": + STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} - AWS_STORAGE_BUCKET_NAME = env.str('S3_BUCKET', 'static') + AWS_STORAGE_BUCKET_NAME = env.str("S3_BUCKET", "static") - AWS_ACCESS_KEY_ID = env.str('S3_ACCESS_KEY') - AWS_SECRET_ACCESS_KEY = env.str('S3_SECRET_KEY') - AWS_S3_ENDPOINT_URL = env.str('S3_ENDPOINT') + AWS_ACCESS_KEY_ID = env.str("S3_ACCESS_KEY") + AWS_SECRET_ACCESS_KEY = env.str("S3_SECRET_KEY") + AWS_S3_ENDPOINT_URL = env.str("S3_ENDPOINT") # allow a custom domain to serve the media files, the used path must _not_ end with a slash - AWS_S3_CUSTOM_DOMAIN = env.url('MEDIA_URL', default=AWS_S3_ENDPOINT_URL).netloc + AWS_S3_CUSTOM_DOMAIN = env.url("MEDIA_URL", default=AWS_S3_ENDPOINT_URL).netloc -elif storage_type == 'sftp': - STORAGES['default'] = {'BACKEND': 'storages.backend.sftpstorage.SFTPStorage'} - SFTP_STORAGE_HOST = env.str('SFTP_HOST') - SFTP_STORAGE_ROOT = env.str('SFTP_DIR') +elif storage_type == "sftp": + STORAGES["default"] = {"BACKEND": "storages.backend.sftpstorage.SFTPStorage"} + SFTP_STORAGE_HOST = env.str("SFTP_HOST") + SFTP_STORAGE_ROOT = env.str("SFTP_DIR") SFTP_STORAGE_INTERACTIVE = False - SFTP_STORAGE_PARAMS = env.dict('SFTP_PARAMS') + SFTP_STORAGE_PARAMS = env.dict("SFTP_PARAMS") -elif storage_type == 'local' or storage_type == '': - if storage_type == '': +elif storage_type == "local" or storage_type == "": + if storage_type == "": import warnings - warnings.warn('No STORAGE_TYPE selected, defaulting to "local". Verify if that is what you want!', RuntimeWarning) - STORAGES['default'] = { + warnings.warn( + 'No STORAGE_TYPE selected, defaulting to "local". Verify if that is what you want!', + RuntimeWarning, + ) + + STORAGES["default"] = { "BACKEND": "django.core.files.storage.FileSystemStorage", - 'OPTIONS': { - 'base_url': env('MEDIA_URL'), - 'location': env.path('MEDIA_PATH', BASE_DIR.parent / 'media'), - } + "OPTIONS": { + "base_url": env("MEDIA_URL"), + "location": env.path("MEDIA_PATH", BASE_DIR.parent / "media"), + }, } else: - raise ValueError(f'Please specify storage type (s3/sftp/local), "{storage_type}" is unknown.') + raise ValueError( + f'Please specify storage type (s3/sftp/local), "{storage_type}" is unknown.' + ) # Media (uploaded files) -MEDIA_URL = env('MEDIA_URL') -MEDIA_ROOT = BASE_DIR.parent / 'media' +MEDIA_URL = env("MEDIA_URL") +MEDIA_ROOT = BASE_DIR.parent / "media" # serve media files in MEDIA_ROOT at /media/ (ignoring configured MEDIA_URL, discouraged in production!) FORCE_SERVE_MEDIA = False # REST Framework Config REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework.authentication.SessionAuthentication', - 'rest_framework.authentication.TokenAuthentication', + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.TokenAuthentication", ] } # Cache -if env('REDIS_URL'): +if env("REDIS_URL"): CACHES = { - 'default': { - 'BACKEND': 'django_redis.cache.RedisCache', - 'LOCATION': env('REDIS_URL'), - 'OPTIONS': { - 'CLIENT_CLASS': 'django_redis.client.DefaultClient', - 'SERIALIZER': 'django_redis.serializers.json.JSONSerializer', + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": env("REDIS_URL"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "SERIALIZER": "django_redis.serializers.json.JSONSerializer", }, } } else: CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", } } # Header to look up real client IP in. Used for logging / rate limiting purposes. `None` = don't lookup, looked up in `request.META` # see https://docs.djangoproject.com/en/3.2/ref/request-response/#django.http.HttpRequest.META for details -CLIENT_IP_HEADER = env('CLIENT_IP_HEADER') -RATELIMIT_ENABLE = not env('DISABLE_RATELIMIT') +CLIENT_IP_HEADER = env("CLIENT_IP_HEADER") +RATELIMIT_ENABLE = not env("DISABLE_RATELIMIT") # List of disallowed assembly slugs -FORBIDDEN_ASSEMBLY_SLUGS = ['admin', 'visit', 'maps', 'api', 'pusher'] +FORBIDDEN_ASSEMBLY_SLUGS = ["admin", "visit", "maps", "api", "pusher"] # Logging configuration LOGGING = { @@ -396,22 +427,22 @@ LOGGING = { }, "root": { "handlers": ["console"], - "level": env('LOG_LEVEL'), + "level": env("LOG_LEVEL"), }, } # Mail configuration -DEFAULT_FROM_EMAIL = env.str('MAIL_FROM') +DEFAULT_FROM_EMAIL = env.str("MAIL_FROM") SERVER_EMAIL = DEFAULT_FROM_EMAIL MAIL_REPLY_TO = [] -MAIL_SUBJECT_PREFIX = env.str('MAIL_SUBJECT_PREFIX', default='') -MAIL_SIGNATURE = env.str('MAIL_SIGNATURE', default='') +MAIL_SUBJECT_PREFIX = env.str("MAIL_SUBJECT_PREFIX", default="") +MAIL_SIGNATURE = env.str("MAIL_SIGNATURE", default="") SUPPORT_HTML_MAILS = False -ADMINS = getaddresses([env('ADMINS', default='')]) +ADMINS = getaddresses([env("ADMINS", default="")]) # try to parse mail config as URL, e.g. smtp+tls://user%40gmail.com:password@mx.local:587 -mail_config_str = env.str('MAIL_CONFIG', default=None) +mail_config_str = env.str("MAIL_CONFIG", default=None) if mail_config_str: mail_config = environ.FileAwareEnv.email_url_config(mail_config_str) # update local vars with the parsed EMAIL_HOST, EMAIL_HOST_USER, etc. @@ -421,25 +452,25 @@ if mail_config_str: HEADERS_DEBUG_ENDPOINT_TOKEN = None # Sentry -SENTRY_ENDPOINT = env('SENTRY_ENDPOINT') -SENTRY_TRACES_SAMPLE_RATE = env('SENTRY_TRACES_SAMPLE_RATE') -SENTRY_PROFILES_SAMPLE_RATE = env('SENTRY_PROFILES_SAMPLE_RATE') +SENTRY_ENDPOINT = env("SENTRY_ENDPOINT") +SENTRY_TRACES_SAMPLE_RATE = env("SENTRY_TRACES_SAMPLE_RATE") +SENTRY_PROFILES_SAMPLE_RATE = env("SENTRY_PROFILES_SAMPLE_RATE") # API access API_USERS = [] # SSO -SSO_COOKIE_NAME = 'SSO_TOKEN' -SSO_COOKIE_DOMAIN = '.localhost' -SSO_HEADER = 'X-SSO-TOKEN' +SSO_COOKIE_NAME = "SSO_TOKEN" +SSO_COOKIE_DOMAIN = ".localhost" +SSO_HEADER = "X-SSO-TOKEN" -SSO_SECRET = env('SSO_SECRET') -if SSO_SECRET is None and env('SSO_SECRET_GENERATE'): +SSO_SECRET = env("SSO_SECRET") +if SSO_SECRET is None and env("SSO_SECRET_GENERATE"): SSO_SECRET = gen_secret_key() # Pretix Token integration, see core.models.ticket.ConferenceMemberTicket.validate_pretix_ticket and plainui.views.RedeemToken -PRETIX_ISSUER = env('PRETIX_ISSUER') # expected value in the 'iss' field of pretix -PRETIX_SECRET_KEY = env('PRETIX_SECRET') # the JWT shared secret with Pretix +PRETIX_ISSUER = env("PRETIX_ISSUER") # expected value in the 'iss' field of pretix +PRETIX_SECRET_KEY = env("PRETIX_SECRET") # the JWT shared secret with Pretix # ---------------------------------- # Metrics @@ -449,7 +480,7 @@ PRETIX_SECRET_KEY = env('PRETIX_SECRET') # the JWT shared secret with Pretix # You can add one or multiple IP addresse by setting the `METRICS_SERVER_IPS` env var. # e.g. METRICS_SERVER_IPS="127.0.0.1,80.147.140.51" # The default is to allow every IP address (METRICS_SERVER_IPS="*"). -METRICS_SERVER_IPS = env('METRICS_SERVER_IPS') +METRICS_SERVER_IPS = env("METRICS_SERVER_IPS") # ---------------------------------- @@ -457,8 +488,8 @@ METRICS_SERVER_IPS = env('METRICS_SERVER_IPS') # ---------------------------------- SCHEDULE_SUPPORT = [ - 'core.schedules.schedulexml.ScheduleXMLSupport', - 'core.schedules.schedulejson.ScheduleJSONSupport', + "core.schedules.schedulexml.ScheduleXMLSupport", + "core.schedules.schedulejson.ScheduleJSONSupport", ] # the file:// protocol should only be used in the unit tests and _not_ in production! @@ -469,66 +500,66 @@ SCHEDULES_SUPPORT_FILE_PROTOCOL = False # ---------------------------------- # BigBlueButton -INTEGRATIONS_BBB = env('BIGBLUEBUTTON') -BIGBLUEBUTTON_API_URL = env('BIGBLUEBUTTON_API_URL') -BIGBLUEBUTTON_API_TOKEN = env('BIGBLUEBUTTON_API_TOKEN') +INTEGRATIONS_BBB = env("BIGBLUEBUTTON") +BIGBLUEBUTTON_API_URL = env("BIGBLUEBUTTON_API_URL") +BIGBLUEBUTTON_API_TOKEN = env("BIGBLUEBUTTON_API_TOKEN") BIGBLUEBUTTON_END_MEETING_CALLBACK = env( - 'BIGBLUEBUTTON_END_MEETING_CALLBACK' + "BIGBLUEBUTTON_END_MEETING_CALLBACK" ) # url bbb will call to notify us of ending meetings # BIGBLUEBUTTON_END_MEETING_CALLBACK = 'https://rc3.world/api/bbb_meeting_end' BIGBLUEBUTTON_INITIAL_PRESENTATION_URL = env( - 'BIGBLUEBUTTON_INITIAL_PRESENTATION_URL' + "BIGBLUEBUTTON_INITIAL_PRESENTATION_URL" ) # url to tell bbb to use as initial presentation # BIGBLUEBUTTON_INITIAL_PRESENTATION_URL = 'https://rc3.world/static/plainui/bbb-background.jpg' # Hangar -INTEGRATIONS_HANGAR = env('HANGAR') -HANGAR_URL = env('HANGAR_URL') +INTEGRATIONS_HANGAR = env("HANGAR") +HANGAR_URL = env("HANGAR_URL") # Workadventure -INTEGRATIONS_WORKADVENTURE = env('WORKADVENTURE') -WORKADVENTURE_URL_SCHEME_GENERAL = env('WORKADVENTURE_URL_SCHEME_GENERAL') -WORKADVENTURE_URL_SCHEME_ASSEMBLY = env('WORKADVENTURE_URL_SCHEME_ASSEMBLY') -WORKADVENTURE_URL_SCHEME_REGISTER = env('WORKADVENTURE_URL_SCHEME_REGISTER') -WORKADVENTURE_CORS_ORIGIN = env('WORKADVENTURE_CORS_ORIGIN') +INTEGRATIONS_WORKADVENTURE = env("WORKADVENTURE") +WORKADVENTURE_URL_SCHEME_GENERAL = env("WORKADVENTURE_URL_SCHEME_GENERAL") +WORKADVENTURE_URL_SCHEME_ASSEMBLY = env("WORKADVENTURE_URL_SCHEME_ASSEMBLY") +WORKADVENTURE_URL_SCHEME_REGISTER = env("WORKADVENTURE_URL_SCHEME_REGISTER") +WORKADVENTURE_CORS_ORIGIN = env("WORKADVENTURE_CORS_ORIGIN") WORKADVENTURE_TERMINATE_OLD_SESSIONS_ON_REGISTER = False # URL (and optional Authorization-Header value) for pushing maps to exneuland, accepts "%CONFSLUG% variable -WORKADVENTURE_BACKEND_MAP_PUSH_URL = env('WORKADVENTURE_BACKEND_MAP_PUSH_URL') +WORKADVENTURE_BACKEND_MAP_PUSH_URL = env("WORKADVENTURE_BACKEND_MAP_PUSH_URL") WORKADVENTURE_BACKEND_MAP_PUSH_AUTHORIZATION = env( - 'WORKADVENTURE_BACKEND_MAP_PUSH_AUTHORIZATION' + "WORKADVENTURE_BACKEND_MAP_PUSH_AUTHORIZATION" ) # URL (and optional Authorization-Header value) for pushing userinfo to exneuland, accepts "%CONFSLUG% and %USERID% variables -WORKADVENTURE_BACKEND_USERINFO_PUSH_URL = env('WORKADVENTURE_BACKEND_USERINFO_PUSH_URL') +WORKADVENTURE_BACKEND_USERINFO_PUSH_URL = env("WORKADVENTURE_BACKEND_USERINFO_PUSH_URL") WORKADVENTURE_BACKEND_USERINFO_PUSH_AUTHORIZATION = env( - 'WORKADVENTURE_BACKEND_USERINFO_PUSH_AUTHORIZATION' + "WORKADVENTURE_BACKEND_USERINFO_PUSH_AUTHORIZATION" ) # URL (and optional Authorization-Header value) for requesting mapservice sync, accepts "%CONFSLUG% variable -WORKADVENTURE_MAPSERVICE_SYNC_URL = env('WORKADVENTURE_MAPSERVICE_SYNC_URL') +WORKADVENTURE_MAPSERVICE_SYNC_URL = env("WORKADVENTURE_MAPSERVICE_SYNC_URL") WORKADVENTURE_MAPSERVICE_SYNC_AUTHORIZATION = env( - 'WORKADVENTURE_MAPSERVICE_SYNC_AUTHORIZATION' + "WORKADVENTURE_MAPSERVICE_SYNC_AUTHORIZATION" ) -WORKADVENTURE_MAPSERVICE_SYNC_NOVERIFY = env('WORKADVENTURE_MAPSERVICE_SYNC_NOVERIFY') +WORKADVENTURE_MAPSERVICE_SYNC_NOVERIFY = env("WORKADVENTURE_MAPSERVICE_SYNC_NOVERIFY") # URL (and optional Authorization-Header value) for requesting mapservice sync of a single room, accepts "%CONFSLUG% and %ROOMID% variable -WORKADVENTURE_MAPSERVICE_SYNCROOM_URL = env('WORKADVENTURE_MAPSERVICE_SYNCROOM_URL') +WORKADVENTURE_MAPSERVICE_SYNCROOM_URL = env("WORKADVENTURE_MAPSERVICE_SYNCROOM_URL") WORKADVENTURE_MAPSERVICE_SYNCROOM_AUTHORIZATION = env( - 'WORKADVENTURE_MAPSERVICE_SYNCROOM_AUTHORIZATION' + "WORKADVENTURE_MAPSERVICE_SYNCROOM_AUTHORIZATION" ) WORKADVENTURE_MAPSERVICE_SYNCROOM_NOVERIFY = env( - 'WORKADVENTURE_MAPSERVICE_SYNCROOM_NOVERIFY' + "WORKADVENTURE_MAPSERVICE_SYNCROOM_NOVERIFY" ) # URL (and optional Authorization-Header value) for setting mapservice's loglevel, accepts "%CONFSLUG% variable -WORKADVENTURE_MAPSERVICE_LOGLEVEL_URL = env('WORKADVENTURE_MAPSERVICE_LOGLEVEL_URL') +WORKADVENTURE_MAPSERVICE_LOGLEVEL_URL = env("WORKADVENTURE_MAPSERVICE_LOGLEVEL_URL") WORKADVENTURE_MAPSERVICE_LOGLEVEL_AUTHORIZATION = env( - 'WORKADVENTURE_MAPSERVICE_LOGLEVEL_AUTHORIZATION' + "WORKADVENTURE_MAPSERVICE_LOGLEVEL_AUTHORIZATION" ) WORKADVENTURE_MAPSERVICE_LOGLEVEL_NOVERIFY = env( - 'WORKADVENTURE_MAPSERVICE_LOGLEVEL_NOVERIFY' + "WORKADVENTURE_MAPSERVICE_LOGLEVEL_NOVERIFY" ) # push maps to backend upon new data from mapservice? @@ -539,35 +570,39 @@ WORKADVENTURE_BACKEND_PUSH_ON_MAPSERVICE_DATA = True # ---------------------------------- # base domain of the frontend (used to generate links in API and BackOffice, not in the plain UI itself) -PLAINUI_BASE_URL = env('PLAINUI_BASE_URL') +PLAINUI_BASE_URL = env("PLAINUI_BASE_URL") # Database ID of the active conference for the PlainUI. # To initialize create a conference in the backend, then configure the conference, then restart the UI workers -PLAINUI_CONFERENCE = env('PLAINUI_CONFERENCE') +PLAINUI_CONFERENCE = env("PLAINUI_CONFERENCE") +# archive mode disables user login etc. +ARCHIVE_MODE = env("ARCHIVE_MODE") # Absolute url to use when dereferring links. Replaces `{quoted_target}` with the urlquoted target url # If you modify this on a non-empty database you should run `manage.py rerender_markdown` to update cashed markdown that contains old urls. -PLAINUI_DEREFERER_URL = env('PLAINUI_DEREFERER_URL') +PLAINUI_DEREFERER_URL = env("PLAINUI_DEREFERER_URL") # URI patterns that are auto-allowed (never get dereferrer'd) # Only looks at schema and host/port, so for example for the url https://rc3.world:4321/some/where/ the entry should be `'https://rc3.world'`. # Can also be a regex, for example re.compile(r'https://.*\.rc3\.world') # DON'T ADD TRAILING SLASHES DEREFERRER_GLOBAL_ALLOWLIST = [] -_handle_hostpattern_list(DEREFERRER_GLOBAL_ALLOWLIST, env('PLAINUI_DEREFERER_ALLOWLIST')) +_handle_hostpattern_list( + DEREFERRER_GLOBAL_ALLOWLIST, env("PLAINUI_DEREFERER_ALLOWLIST") +) # URI patterns where following outgoing links is counted in the database # rules are the same as with DEREFERRER_GLOBAL_ALLOWLIST DEREFERRER_COUNT_ACCESS = [] -_handle_hostpattern_list(DEREFERRER_COUNT_ACCESS, env('PLAINUI_DEREFERER_COUNTLIST')) +_handle_hostpattern_list(DEREFERRER_COUNT_ACCESS, env("PLAINUI_DEREFERER_COUNTLIST")) # Shibboleet Mode: an entrypoint /shibboleet which adds the registered user to the conference and redirects to a new WA session # ID of the WorkAdventure room which shall be used as the entrypoint in the new WA session -SHIBBOLEET_WA_ROOM_ID = env('SHIBBOLEET_WA_ROOM_ID') +SHIBBOLEET_WA_ROOM_ID = env("SHIBBOLEET_WA_ROOM_ID") # Slug of the Lobby Room. Default Room to join in the Workadventure -WORKADVENTURE_LOBBY_ROOM_SLUG = 'lobby' +WORKADVENTURE_LOBBY_ROOM_SLUG = "lobby" # Protected namespaces for static pages. @@ -577,11 +612,14 @@ WORKADVENTURE_LOBBY_ROOM_SLUG = 'lobby' # To grant access for multiple page groups, create multiple entries for the same namespace. STATIC_PAGE_NAMESPACES = ( # ('<Namespace-Prefix>', '<Required Static Pages-Group>'), - ('_', 'conference_organizers'), # all pages starting with underscore (includes for other pages) - ('report_content', 'conference_organizers'), - ('start', 'conference_organizers'), - ('wa_contact', 'conference_organizers'), + ( + "_", + "conference_organizers", + ), # all pages starting with underscore (includes for other pages) + ("report_content", "conference_organizers"), + ("start", "conference_organizers"), + ("wa_contact", "conference_organizers"), ) # flag if newly created pages are localized by default (or not) -STATIC_PAGE_LOCALIZED_BY_DEFAULT = env.bool('STATICPAGES_LOCALIZED_BY_DEFAULT', False) +STATIC_PAGE_LOCALIZED_BY_DEFAULT = env.bool("STATICPAGES_LOCALIZED_BY_DEFAULT", False) diff --git a/src/plainui/jinja2.py b/src/plainui/jinja2.py index 760b2b6f03ca53d5ee56d3bbc90ce27f593e7427..90ef5612c08758f87131a6c16961a46a6f5df4f0 100644 --- a/src/plainui/jinja2.py +++ b/src/plainui/jinja2.py @@ -1,4 +1,7 @@ from datetime import datetime, timedelta + + +from django.conf import settings from django.contrib.messages import get_messages from django.templatetags.static import static from django.urls import reverse @@ -17,6 +20,7 @@ from modeltranslation.fields import build_localized_fieldname from modeltranslation.settings import AVAILABLE_LANGUAGES from core.models import PlatformUser, UserBadge +from core.templatetags.hub_absolute import hub_absolute, hub_absolute_self def url(name, *args, current_app=None, **kwargs): @@ -24,11 +28,11 @@ def url(name, *args, current_app=None, **kwargs): def browser_type(request): - user_agent = request.META.get('HTTP_USER_AGENT', '') - if 'MSIE' in user_agent or 'Trident/7.0;' in user_agent: - return 'ie' - if 'CERN-NextStep-WorldWideWeb' in user_agent: - return 'historic' + user_agent = request.META.get("HTTP_USER_AGENT", "") + if "MSIE" in user_agent or "Trident/7.0;" in user_agent: + return "ie" + if "CERN-NextStep-WorldWideWeb" in user_agent: + return "historic" return user_agent @@ -43,7 +47,11 @@ def num_of_pending_badges(request): user = request.user if not user.is_authenticated: return 0 - return len(UserBadge.objects.filter(user=user, accepted_by_user=False).select_related('badge')) + return len( + UserBadge.objects.filter(user=user, accepted_by_user=False).select_related( + "badge" + ) + ) def custom_timedelta(tdelta): @@ -52,51 +60,51 @@ def custom_timedelta(tdelta): def custom_timedelta_short(tdelta: timedelta): if not isinstance(tdelta, timedelta): - return '' + return "" s = tdelta.seconds h = s // 3600 s -= h * 3600 m = s // 60 s -= m * 60 - return '%02d:%02d:%02d' % (h, m, s) + return "%02d:%02d:%02d" % (h, m, s) def custom_strftime(date): if not isinstance(date, datetime): - return '' + return "" return localize(localtime(date)) def custom_strftimehm(date): if not isinstance(date, datetime): - return '' + return "" - return localtime(date).strftime('%H:%M') + return localtime(date).strftime("%H:%M") def custom_strfdate(date): if not isinstance(date, datetime): - return '' + return "" return localize(localdate(date)) def custom_strfdates(date): if not isinstance(date, datetime): - return '' + return "" - if get_language() == 'de': - return localtime(date).strftime('%d.%m.%Y') + if get_language() == "de": + return localtime(date).strftime("%d.%m.%Y") - return localdate(date).strftime('%x') + return localdate(date).strftime("%x") def custom_weekday_abbrev(date): if not isinstance(date, datetime): - return '' + return "" - return date.strftime('%a') + return date.strftime("%a") # set up an internal represenative for an unset variable as parameter for show_vars() @@ -113,12 +121,12 @@ def show_vars(ctx, var=_UNSET): var = var._wrapped try: - if not callable(getattr(var, 'items', None)): + if not callable(getattr(var, "items", None)): var = var.__dict__ - ret = '' - for (k, v) in var.items(): - ret += '%r: %r\n' % (k, v) + ret = "" + for k, v in var.items(): + ret += "%r: %r\n" % (k, v) return ret except (AttributeError, TypeError): @@ -127,24 +135,24 @@ def show_vars(ctx, var=_UNSET): @pass_context def css_scope(ctx): - request = ctx['request'] + request = ctx["request"] if request.user.is_authenticated and request.user.high_contrast: - return 'hub-high-contrast' - scope = ctx.get('scope') - if scope == 'assembly': - return 'hub-assembly' - elif scope == 'world': - return 'hub-world' - return 'hub' + return "hub-high-contrast" + scope = ctx.get("scope") + if scope == "assembly": + return "hub-assembly" + elif scope == "world": + return "hub-world" + return "hub" @pass_context def active_theme(ctx): - request = ctx['request'] - if 'theme_override' in ctx: - return ctx['theme_override'] - theme = request.session.get('theme', PlatformUser.Theme.DEFAULT) + request = ctx["request"] + if "theme_override" in ctx: + return ctx["theme_override"] + theme = request.session.get("theme", PlatformUser.Theme.DEFAULT) return theme @@ -167,7 +175,7 @@ class MyContext(Context): self.next_id = 0 def get_next_id(self): - res = 'id_' + str(self.next_id) + res = "id_" + str(self.next_id) self.next_id += 1 return res @@ -177,33 +185,37 @@ class MyEnvironment(Environment): def environment(**options): - env = MyEnvironment(**{ - 'extensions': ["jinja2.ext.i18n", "jinja2.ext.debug"], - **options - }) - env.globals.update({ - 'browser_type': browser_type, - 'css_scope': css_scope, - 'active_theme': active_theme, - 'get_language': get_language, - 'get_messages': get_messages, - 'json_script': json_script, - 'num_of_unread_messages': num_of_unread_messages, - 'num_of_pending_badges': num_of_pending_badges, - 'static': static, - 'url': url, - 'show_vars': show_vars, - 'unique_id': unique_id, - 'translated_fields_for_field': translated_fields_for_field, - 'field_translation_languages': field_translation_languages, - 'now': timezone.now(), - }) - env.filters['strftdelta'] = custom_timedelta - env.filters['strftdelta_short'] = custom_timedelta_short - env.filters['strftimehm'] = custom_strftimehm - env.filters['strftime'] = custom_strftime - env.filters['strfdate'] = custom_strfdate - env.filters['strfdates'] = custom_strfdates - env.filters['weekday_abbrev'] = custom_weekday_abbrev + env = MyEnvironment( + **{"extensions": ["jinja2.ext.i18n", "jinja2.ext.debug"], **options} + ) + env.globals.update( + { + "archive_mode": settings.ARCHIVE_MODE, + "browser_type": browser_type, + "css_scope": css_scope, + "active_theme": active_theme, + "get_language": get_language, + "get_messages": get_messages, + "json_script": json_script, + "hub_absolute": hub_absolute, + "hub_absolute_self": hub_absolute_self, + "num_of_unread_messages": num_of_unread_messages, + "num_of_pending_badges": num_of_pending_badges, + "static": static, + "url": url, + "show_vars": show_vars, + "unique_id": unique_id, + "translated_fields_for_field": translated_fields_for_field, + "field_translation_languages": field_translation_languages, + "now": timezone.now(), + } + ) + env.filters["strftdelta"] = custom_timedelta + env.filters["strftdelta_short"] = custom_timedelta_short + env.filters["strftimehm"] = custom_strftimehm + env.filters["strftime"] = custom_strftime + env.filters["strfdate"] = custom_strfdate + env.filters["strfdates"] = custom_strfdates + env.filters["weekday_abbrev"] = custom_weekday_abbrev env.install_gettext_callables(gettext, ngettext, newstyle=True) return env diff --git a/src/plainui/jinja2/plainui/assembly.html b/src/plainui/jinja2/plainui/assembly.html index 8ed75d62dbb63f750eeee8a851b225b68cc0963e..eb8b9e080ba99e6e5f752eb97f37376afd74c9ee 100644 --- a/src/plainui/jinja2/plainui/assembly.html +++ b/src/plainui/jinja2/plainui/assembly.html @@ -41,6 +41,11 @@ <ul class="m-0 px-3 pb-0 pt-3 flex-grow-1 list-unstyled d-flex flex-row flex-wrap justify-content-start align-items-center"> {% for person in spokespeople %} <li class="pe-3 mb-3"> + {% if not archive_mode %} + <a href="{{ url('plainui:user', user_slug=person.member.slug) }}" class="a a-bold">{{person.member.display_name}}</a> + {% else %} + <label class="a a-bold">{{person.member.display_name}}</label> + {% endif %} <a href="{{ url('plainui:user', user_slug=person.member.slug) }}" class="a a-bold">{{person.member.display_name}}</a> </li> {% endfor %} diff --git a/src/plainui/jinja2/plainui/board.html b/src/plainui/jinja2/plainui/board.html index 93fd8af7402a2a1ec8c7b68f32b72070d5f0d52b..22375c8433be501e0e5cb132773d4610f969a25f 100644 --- a/src/plainui/jinja2/plainui/board.html +++ b/src/plainui/jinja2/plainui/board.html @@ -5,6 +5,7 @@ {% block content %} {{ titleMacro.title(_("Bulletin Board")) }} + {% if not archive_mode %} <div class="my-3"> <ul class="mb-0 list-unstyled d-flex justify-content-center"> {#<li class="col-12 mb-3"> @@ -81,5 +82,7 @@ {%- endif %} <hr class="hub-spacer"> - + {% else %}{# archive mode #} + {{ _('archivemode_notavailable') }} + {% endif %} {% endblock %} diff --git a/src/plainui/jinja2/plainui/components/function_btns.html b/src/plainui/jinja2/plainui/components/function_btns.html index 3ca5005bc44bae9e7660cec743529a473ffcb2fa..80252b22e106e055ada341ae606a7b6ad11be83e 100644 --- a/src/plainui/jinja2/plainui/components/function_btns.html +++ b/src/plainui/jinja2/plainui/components/function_btns.html @@ -6,6 +6,7 @@ #} {% macro fav(fav_id, fav_type, fav_is, color="transparent", next=request.get_full_path() ) -%} + {% if not archive_mode %} <form action="{{ url('plainui:modify_favorites') }}" class="d-inline-block" method="POST"> {{ csrf_input }} <input type="hidden" name="next" value="{{ next ~ '#fav_' ~ fav_id}}"> @@ -30,9 +31,11 @@ </button> {% endif -%} </form> + {% endif %} {%- endmacro %} {% macro schedule(sch_id, sch_is, color="transparent", next=request.get_full_path() ) -%} + {% if not archive_mode %} <form action="{{ url('plainui:modify_personal_calendar') }}" class="d-inline-block" method="POST"> {{ csrf_input }} <input type="hidden" name="next" value="{{ next ~ '#sch_' ~ sch_id }}"> @@ -54,6 +57,7 @@ </button> {% endif -%} </form> + {% endif %} {%- endmacro %} {% macro share(share_url, title=_("share this "), color="transparent" ) -%} @@ -75,18 +79,22 @@ {%- endmacro %} # {% macro report(report_url, kind="url", next=request.get_full_path() , title=_("report this url"), color="transparent" ) -%} + {% if not archive_mode %} <a href="{{ url('plainui:report_content') ~ '?kind=' ~ kind ~ '&kind_data=' ~ report_url | urlencode ~ '&next=' ~ next }}" class="me-2 btn-icon-big btn btn-{{ color }}" title="{{ title }}"> <svg width="1.25rem" height="1.25rem" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" style="enable-background:new 0 0 500 500" fill="currentColor"> <style>.st0{display:none}.st1{display:inline}.st2{fill:none;stroke:#000;stroke-width:18;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10}</style> <g><path d="M248.37 310.4c-9.86 0-16.38 1.9-19.56 5.7-3.19 3.8-4.78 10.01-4.78 18.63 0 8.83 1.59 15.1 4.78 18.79 3.18 3.7 9.7 5.54 19.56 5.54 9.85 0 16.32-1.85 19.4-5.54 3.08-3.7 4.62-9.96 4.62-18.79 0-8.62-1.53-14.83-4.62-18.63-3.08-3.8-9.55-5.7-19.4-5.7zM224.96 160.55c-1.23 1.34-1.85 2.93-1.85 4.78l10.78 126.29h29.57l10.47-126.29c0-1.85-.56-3.44-1.69-4.78-1.13-1.33-3.74-2-7.85-2h-32.03c-3.71 0-6.17.67-7.4 2z"/><path d="M448.65 237.65 262.3 51.3a16.992 16.992 0 0 0-24.04 0L51.9 237.65c-6.64 6.64-6.64 17.4 0 24.04l186.35 186.35a16.992 16.992 0 0 0 24.04 0l186.35-186.35c6.65-6.64 6.65-17.4.01-24.04zM250.27 411.98 87.96 249.67 250.28 87.36l162.31 162.31-162.32 162.31z"/></g> </svg> </a> + {% endif %} {%- endmacro %} {% macro edit(edit, title=_("edit this"), color="transparent" ) -%} + {% if not archive_mode %} <a href="{{ edit }}" class="me-2 btn-icon-big btn btn-{{ color }}" title="{{ title }}"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16"> <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> </svg> </a> + {% endif %} {%- endmacro %} diff --git a/src/plainui/jinja2/plainui/components/header_buttons.html b/src/plainui/jinja2/plainui/components/header_buttons.html index 285eeb4441bf88a370e20fe610aa33709a4f75e0..2257c9919ba6d0eff02cc3b2ec93ee0ee1037709 100644 --- a/src/plainui/jinja2/plainui/components/header_buttons.html +++ b/src/plainui/jinja2/plainui/components/header_buttons.html @@ -53,6 +53,7 @@ </svg> </a> <ul class="dropdown-menu" aria-labelledby="lang-select"> + {% if not archive_mode %} <form class="hub-header__additional-lang" method="POST" @@ -75,6 +76,10 @@ {{ _("en") }} </button></li> </form> + {% else %} + <li><a class="dropdown-item" href="{{ hub_absolute_self(request, lang='de') }}">{{ _("de") }}</a></li> + <li><a class="dropdown-item" href="{{ hub_absolute_self(request, lang='en') }}">{{ _("en") }}</a></li> + {% endif %} </ul> </div> {%- endmacro %} @@ -139,7 +144,7 @@ {% macro share(share_url, title=_("share this "), color="transparent" ) -%} <a - href="{{ share_url }}" + href="{{ hub_absolute_self(request, i18n=archive_mode) }}" class="btn-icon-big btn-{{ color }} nav-link" title="{{ title }}" target="_blank" diff --git a/src/plainui/jinja2/plainui/dereferrer.html b/src/plainui/jinja2/plainui/dereferrer.html index c62985d378efd9386b47937fafb6760e858c7717..d567e2c7b6381be4195333f91b1f1b84cdb18377 100644 --- a/src/plainui/jinja2/plainui/dereferrer.html +++ b/src/plainui/jinja2/plainui/dereferrer.html @@ -20,11 +20,11 @@ </a> </li> <li class="mx-2"> - <a href='{{ url('plainui:dereferrer_approved', signed_payload=signed_url) }}' class="btn btn-primary " rel="external,noreferrer"> + <a href='{{ url('plainui:dereferrer_approved', signed_payload=signed_url) if not archive_mode else plain_url }}' class="btn btn-primary " rel="external,noreferrer"> {{ _("Follow Link") }} </a> </li> - {% if can_allow %} + {% if can_allow and not archive_mode %} <li class="col"> <a href="{{ url('plainui:dereferrer_save', signed_payload=signed_url) }}" class="btn btn-xl btn-block btn-primary external" rel="external,noreferrer"> {{ _("Follow & Allow '%(domain)s' permanently", domain=domain) }} diff --git a/src/plainui/jinja2/plainui/login.html b/src/plainui/jinja2/plainui/login.html index 37eb9cc9118b999b668c233ccd126ebd1c225e99..4a5f184c2506bb3a348c93232dfee83f318cba59 100644 --- a/src/plainui/jinja2/plainui/login.html +++ b/src/plainui/jinja2/plainui/login.html @@ -10,6 +10,7 @@ </figure> <div class="mw-320 mx-auto"> <h2>{{ _("Login") }}</h2> + {% if not archive_mode %} <form method="POST" id="login" class="hub-landing__form"> <!--<h1 class="text-center bg-secondary p-2 text-dark h3 m-0">{{ _("login") }}</h1>--> <div class=""> @@ -31,7 +32,7 @@ <p class="mb-2 text-white">{{ _("login--cookieinfo") }}</p> <hr class="hub-spacer"> - <h3>{{ _("New here?") }}</h2> + <h3>{{ _("New here?") }}</h3> <div class="d-grid"> <a href="{{ url('plainui:signup') }}" class="btn btn-primary" title="{{ _(" sign up (new account)") }}"> @@ -40,6 +41,9 @@ </div> </div> </form> + {% else %} + <p class="mb-2 text-white">{{ _("archivemode_nologin") }}</p> + {% endif %} </div> <hr class="hub-spacer"> diff --git a/src/plainui/jinja2/plainui/room.html b/src/plainui/jinja2/plainui/room.html index 7f65743cf6a47e40ab3a88cbde1febad5a3dc9fd..c97b384d6293e37eaa337e7d97a07edd785b886f 100644 --- a/src/plainui/jinja2/plainui/room.html +++ b/src/plainui/jinja2/plainui/room.html @@ -14,7 +14,8 @@ <p>{{ _("Assembly") }}: <a href="{{ url('plainui:assembly', assembly_slug=room.assembly.slug) }}" class="a a-bold">{{room.assembly.name}}</a></p> {% endif %} - {% if voc_stream %} + + {% if voc_stream and not archive_mode %} <div class="border m-0 p-0 bg-opaque"> <h2 class="bg-secondary text-center text-dark m-0 px-3 py-1">{{ _("Currently Streaming") }}</h2> <div class="p-3"> diff --git a/src/plainui/jinja2/plainui/signup.html b/src/plainui/jinja2/plainui/signup.html index 4d49361ac1413ec5510dc205a548d4a1492b5d53..f0bbb36cd17891f1d45294c0f6e6d806dedf3da6 100644 --- a/src/plainui/jinja2/plainui/signup.html +++ b/src/plainui/jinja2/plainui/signup.html @@ -11,6 +11,7 @@ <p>{{ _("Registration Info Text") }}</p> + {% if not archive_mode %} <form method="POST" id="registration" @@ -34,6 +35,9 @@ </ul> </div> </form> + {% else %} + <p class="mb-2 text-white">{{ _("archivemode_nologin") }}</p> + {% endif %} <hr class="hub-spacer"> </article> diff --git a/src/plainui/jinja2/plainui/static_page.html b/src/plainui/jinja2/plainui/static_page.html index 2d40b7e395f3d5a577fe11316d827f804a580936..7f5e66be2ae2a4147097abe4baf62892860eae61 100644 --- a/src/plainui/jinja2/plainui/static_page.html +++ b/src/plainui/jinja2/plainui/static_page.html @@ -23,6 +23,7 @@ </div> {%- endif %} {{ titleMacro.title(title=page.title) }} + {% if not archive_mode %} <div class="row"> <div class="col m-2 d-flex justify-content-start"> <a href="{{ url('plainui:static_page_global_history') }}" class="btn btn-dark me-2">{{ _("Global History") }}</a> @@ -38,6 +39,7 @@ <a href="{{ url('plainui:static_page_history', page_slug=page_slug) }}" class="btn btn-dark">{{ _("History") }}</a> </div> </div> + {% endif %} <article class="pb-11"> {{ markdownMacro.markdown(markdown=page_body | safe) }} </article> diff --git a/src/plainui/locale/de/LC_MESSAGES/django.po b/src/plainui/locale/de/LC_MESSAGES/django.po index 15851830692453d6ae6a9e5f503e2e7ef4c2e0bf..f1d0c7692b59261160a1e6063c93133d81f568b9 100644 --- a/src/plainui/locale/de/LC_MESSAGES/django.po +++ b/src/plainui/locale/de/LC_MESSAGES/django.po @@ -143,6 +143,9 @@ msgstr "Village Badges" msgid "No badges publicly available." msgstr "Es sind keine Badges öffentlich verfügbar. " +msgid "archivemode_notavailable" +msgstr "Diese Funktion steht nicht mehr zur Verfügung, da die Webseite archiviert wurde." + msgid "Your Browser is broken. Get a better one here!" msgstr "Dein Browser ist defekt. Hier bekommst du einen Besseren!" @@ -333,6 +336,9 @@ msgstr "Kapazität" msgid "Tags" msgstr "Tags" +msgid "archivemode_loginlink_hint" +msgstr "nicht verfügbar, Seite ist archiviert" + msgid "no tags availaible" msgstr "Nicht getaggt." @@ -438,6 +444,9 @@ msgstr "Neu hier?" msgid "sign up (new account)" msgstr "Account anlegen" +msgid "archivemode_nologin" +msgstr "Diese Webseite wurde archiviert, ein Login ist nicht mehr möglich." + msgid "Profile" msgstr "Profil" diff --git a/src/plainui/locale/en/LC_MESSAGES/django.po b/src/plainui/locale/en/LC_MESSAGES/django.po index 5fcb31889a0dc5aa3e3455631e032e97dc46ee7c..b3007dca85de5af170ccefad3269a0f97ace0d1d 100644 --- a/src/plainui/locale/en/LC_MESSAGES/django.po +++ b/src/plainui/locale/en/LC_MESSAGES/django.po @@ -140,6 +140,9 @@ msgstr "village rooms" msgid "assembly badges" msgstr "village badges" +msgid "archivemode_notavailable" +msgstr "This function is not available any more as this website has been archived." + msgid "No badges publicly available." msgstr "" @@ -330,6 +333,9 @@ msgstr "" msgid "capacity" msgstr "" +msgid "archivemode_loginlink_hint" +msgstr "not available, page has been archived" + msgid "Tags" msgstr "" @@ -438,6 +444,9 @@ msgstr "New here?" msgid "sign up (new account)" msgstr "Sign up" +msgid "archivemode_nologin" +msgstr "This website has been archived, logging in is not possible any more." + msgid "Profile" msgstr ""