From 63839dc22b55a742c34cf09d0d2d3fcaaad2ced1 Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 28 Dec 2024 17:33:26 +0100 Subject: [PATCH 01/20] Fix poster edition and display bug --- com/templates/com/screen_slideshow.jinja | 2 +- com/views.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/com/templates/com/screen_slideshow.jinja b/com/templates/com/screen_slideshow.jinja index 448f8dfc..0374257e 100644 --- a/com/templates/com/screen_slideshow.jinja +++ b/com/templates/com/screen_slideshow.jinja @@ -3,7 +3,7 @@ {% trans %}Slideshow{% endtrans %} - + diff --git a/com/views.py b/com/views.py index 1b7ab8bc..f9993b3c 100644 --- a/com/views.py +++ b/com/views.py @@ -685,8 +685,12 @@ class PosterEditBaseView(UpdateView): def get_initial(self): return { - "date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S"), - "date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S"), + "date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S") + if self.object.date_begin + else None, + "date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S") + if self.object.date_end + else None, } def dispatch(self, request, *args, **kwargs): From 0d1629495bea7910f3d02b1428f0c59a1869eccc Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 28 Dec 2024 18:54:42 +0100 Subject: [PATCH 02/20] Refactor com scss and add basic unified event calendar --- com/api.py | 53 ++ .../com/components/ics-calendar-index.ts | 75 +++ com/static/com/components/ics-calendar.scss | 60 ++ com/static/com/css/news-detail.scss | 66 ++ com/static/com/css/news-list.scss | 298 +++++++++ com/static/com/css/posters.scss | 230 +++++++ com/templates/com/news_detail.jinja | 5 + com/templates/com/news_list.jinja | 25 +- com/templates/com/poster_list.jinja | 4 + com/templates/com/poster_moderate.jinja | 4 + core/management/commands/populate.py | 4 +- core/static/core/devices.scss | 5 + core/static/core/style.scss | 600 +----------------- core/templates/core/poster_list.jinja | 54 -- package-lock.json | 52 ++ package.json | 7 +- poetry.lock | 84 ++- pyproject.toml | 1 + sith/settings.py | 1 + tsconfig.json | 3 +- 20 files changed, 963 insertions(+), 668 deletions(-) create mode 100644 com/api.py create mode 100644 com/static/bundled/com/components/ics-calendar-index.ts create mode 100644 com/static/com/components/ics-calendar.scss create mode 100644 com/static/com/css/news-detail.scss create mode 100644 com/static/com/css/news-list.scss create mode 100644 com/static/com/css/posters.scss create mode 100644 core/static/core/devices.scss delete mode 100644 core/templates/core/poster_list.jinja diff --git a/com/api.py b/com/api.py new file mode 100644 index 00000000..64f375fd --- /dev/null +++ b/com/api.py @@ -0,0 +1,53 @@ +from datetime import timedelta + +import urllib3 +from django.core.cache import cache +from django.http import HttpResponse +from django.utils import timezone +from ics import Calendar, Event +from ninja_extra import ControllerBase, api_controller, route + +from com.models import NewsDate + + +@api_controller("/calendar") +class CalendarController(ControllerBase): + @route.get("/external.ics") + def calendar_external(self): + CACHE_KEY = "external_calendar" + if cached := cache.get(CACHE_KEY): + return HttpResponse( + cached, + content_type="text/calendar", + status=200, + ) + calendar = urllib3.request( + "GET", + "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics", + ) + if calendar.status == 200: + cache.set(CACHE_KEY, calendar.data, 3600) # Cache for one hour + return HttpResponse( + calendar.data, + content_type="text/calendar", + status=calendar.status, + ) + + @route.get("/internal.ics") + def calendar_internal(self): + calendar = Calendar() + for news_date in NewsDate.objects.filter( + news__is_moderated=True, + start_date__lte=timezone.now() + timedelta(days=30), + ).prefetch_related("news"): + event = Event( + name=news_date.news.title, + begin=news_date.start_date, + end=news_date.end_date, + ) + calendar.events.add(event) + + return HttpResponse( + calendar.serialize().encode("utf-8"), + content_type="text/calendar", + ) diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts new file mode 100644 index 00000000..f88b9b0f --- /dev/null +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -0,0 +1,75 @@ +import { makeUrl } from "#core:utils/api"; +import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; +import { Calendar } from "@fullcalendar/core"; +import enLocale from "@fullcalendar/core/locales/en-gb"; +import frLocale from "@fullcalendar/core/locales/fr"; +import dayGridPlugin from "@fullcalendar/daygrid"; +import iCalendarPlugin from "@fullcalendar/icalendar"; +import listPlugin from "@fullcalendar/list"; +import { calendarCalendarExternal, calendarCalendarInternal } from "#openapi"; + +@registerComponent("ics-calendar") +export class IcsCalendar extends inheritHtmlElement("div") { + static observedAttributes = ["locale"]; + private calendar: Calendar; + private locale = "en"; + + attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) { + if (name !== "locale") { + return; + } + + this.locale = newValue; + } + + isMobile() { + return window.innerWidth < 765; + } + + currentView() { + // Get view type based on viewport + return this.isMobile() ? "listMonth" : "dayGridMonth"; + } + + currentToolbar() { + if (this.isMobile()) { + return { + left: "prev,next", + center: "title", + right: "", + }; + } + return { + left: "prev,next today", + center: "title", + right: "dayGridMonth,dayGridWeek,dayGridDay", + }; + } + + async connectedCallback() { + super.connectedCallback(); + this.calendar = new Calendar(this.node, { + plugins: [dayGridPlugin, iCalendarPlugin, listPlugin], + locales: [frLocale, enLocale], + height: "auto", + locale: this.locale, + initialView: this.currentView(), + headerToolbar: this.currentToolbar(), + eventSources: [ + { + url: await makeUrl(calendarCalendarInternal), + format: "ics", + }, + { + url: await makeUrl(calendarCalendarExternal), + format: "ics", + }, + ], + windowResize: () => { + this.calendar.changeView(this.currentView()); + this.calendar.setOption("headerToolbar", this.currentToolbar()); + }, + }); + this.calendar.render(); + } +} diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss new file mode 100644 index 00000000..bb858dd5 --- /dev/null +++ b/com/static/com/components/ics-calendar.scss @@ -0,0 +1,60 @@ +@import "core/static/core/colors"; + + +:root { + --fc-button-border-color: #fff; + --fc-button-hover-border-color: #fff; + --fc-button-active-border-color: #fff; + --fc-button-text-color: #fff; + --fc-button-bg-color: #1a78b3; + --fc-button-active-bg-color: #15608F; + --fc-button-hover-bg-color: #15608F; + --fc-today-bg-color: rgba(26, 120, 179, 0.1); + --fc-border-color: #DDDDDD; + --sc-main-background-color: #f9fafb; + --sc-main-padding: 5px; + --sc-main-border: 0px solid #DDDDDD; + --sc-main-border-radius: 0px; + --sc-body-font-family: Roboto; + --sc-title-font-family: Roboto; + --sc-body-font-size: 16px; + --sc-title-font-size: 28px; + --sc-body-font-weight: 400; + --sc-title-font-weight: 500; + --sc-title-font-color: #111111; + --sc-base-body-font-color: #222222; + --sc-title-font-style: normal; + --sc-body-font-style: normal; + --sc-event-dot-color: #1a78b3; + --sc-button-border: 1px solid #ffffff; + --sc-button-border-radius: 4px; + --sc-button-icons-size: 22px; + --sc-grid-event-white-space: nowrap; + --sc-block-event-background-color-hovered: rgb(245, 245, 245); + --sc-block-event-border: 1px solid rgba(255, 255, 255, 0); + --sc-block-event-border-radius: 2.5px; + --sc-dot-event-background-color: rgba(255, 255, 255, 0); + --sc-dot-event-background-color-hovered: rgb(245, 245, 245); + --sc-dot-event-text-color: #222222; + --sc-dot-event-border: 1px solid rgba(255, 255, 255, 0); + --sc-dot-event-border-radius: 2.5px; + --sc-grid-day-header-background-color: rgba(255, 255, 255, 0); + --sc-list-day-header-background-color: rgba(208, 208, 208, 0.3); + --sc-inner-calendar-background-color: rgba(255, 255, 255, 0); + --sc-past-day-background-color: rgba(255, 255, 255, 0); + --sc-future-day-background-color: rgba(255, 255, 255, 0); + --sc-disabled-day-background-color: rgba(208, 208, 208, 0.3); + --sc-event-overlay-background-color: #FFFFFF; + --sc-event-overlay-padding: 20px; + --sc-event-overlay-border: 1px solid #EEEEEE; + --sc-event-overlay-border-radius: 4px; + --sc-event-overlay-primary-icon-color: #1a78b3; + --sc-event-overlay-secondary-icon-color: #000000; + --sc-event-overlay-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%); + --sc-event-overlay-max-width: 600px; +} + +ics-calendar { + border: none; + box-shadow: none; +} \ No newline at end of file diff --git a/com/static/com/css/news-detail.scss b/com/static/com/css/news-detail.scss new file mode 100644 index 00000000..0a07e62d --- /dev/null +++ b/com/static/com/css/news-detail.scss @@ -0,0 +1,66 @@ +@import "core/static/core/colors"; + +#news_details { + display: inline-block; + margin-top: 20px; + padding: 0.4em; + width: 80%; + background: $white-color; + + h4 { + margin-top: 1em; + text-transform: uppercase; + } + + .club_logo { + display: inline-block; + text-align: center; + width: 19%; + float: left; + min-width: 15em; + margin: 0; + + img { + max-height: 15em; + max-width: 12em; + display: block; + margin: 0 auto; + margin-bottom: 10px; + } + } + + .share_button { + border: none; + color: white; + padding: 0.5em 1em; + text-align: center; + text-decoration: none; + font-size: 1.2em; + border-radius: 2px; + float: right; + display: block; + margin-left: 0.3em; + + &:hover { + color: lightgrey; + } + } + + .facebook { + background: $faceblue; + } + + .twitter { + background: $twitblue; + } + + .news_meta { + margin-top: 10em; + font-size: small; + } +} + +.helptext { + margin-top: 10px; + display: block; +} \ No newline at end of file diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss new file mode 100644 index 00000000..a33e6315 --- /dev/null +++ b/com/static/com/css/news-list.scss @@ -0,0 +1,298 @@ +@import "core/static/core/colors"; +@import "core/static/core/devices"; + +#news { + display: flex; + + @media (max-width: 800px) { + flex-direction: column; + } + + .news_column { + display: inline-block; + margin: 0; + vertical-align: top; + } + + #news_admin { + margin-bottom: 1em; + } + + #right_column { + flex: 20%; + float: right; + margin: 0.2em; + } + + #left_column { + flex: 79%; + margin: 0.2em; + + h3 { + background: $second-color; + box-shadow: $shadow-color 1px 1px 1px; + padding: 0.4em; + margin: 0 0 0.5em 0; + text-transform: uppercase; + font-size: 1.1em; + + &:not(:first-of-type) { + margin: 2em 0 1em 0; + } + } + } + + @media screen and (max-width: $small-devices) { + + #left_column, + #right_column { + flex: 100%; + } + } + + /* AGENDA/BIRTHDAYS */ + #agenda, + #birthdays { + display: block; + width: 100%; + background: white; + font-size: 70%; + margin-bottom: 1em; + + #agenda_title, + #birthdays_title { + margin: 0; + border-radius: 5px 5px 0 0; + box-shadow: $shadow-color 1px 1px 1px; + padding: 0.5em; + font-weight: bold; + font-size: 150%; + text-align: center; + text-transform: uppercase; + background: $second-color; + } + + #agenda_content { + overflow: auto; + box-shadow: $shadow-color 1px 1px 1px; + height: 20em; + } + + #agenda_content, + #birthdays_content { + .agenda_item { + padding: 0.5em; + margin-bottom: 0.5em; + + &:nth-of-type(even) { + background: $secondary-neutral-light-color; + } + + .agenda_time { + font-size: 90%; + color: grey; + } + + .agenda_item_content { + p { + margin-top: 0.2em; + } + } + } + + ul.birthdays_year { + margin: 0; + list-style-type: none; + font-weight: bold; + + >li { + padding: 0.5em; + + &:nth-child(even) { + background: $secondary-neutral-light-color; + } + } + + ul { + margin: 0; + margin-left: 1em; + list-style-type: square; + list-style-position: inside; + font-weight: normal; + } + } + } + } + + /* END AGENDA/BIRTHDAYS */ + + /* EVENTS TODAY AND NEXT FEW DAYS */ + .news_events_group { + box-shadow: $shadow-color 1px 1px 1px; + margin-left: 1em; + margin-bottom: 0.5em; + + .news_events_group_date { + display: table-cell; + padding: 0.6em; + vertical-align: middle; + background: $primary-neutral-dark-color; + color: $white-color; + text-transform: uppercase; + text-align: center; + font-weight: bold; + font-family: monospace; + font-size: 1.4em; + border-radius: 7px 0 0 7px; + + div { + margin: 0 auto; + + .day { + font-size: 1.5em; + } + } + } + + .news_events_group_items { + display: table-cell; + width: 100%; + + .news_event:nth-of-type(odd) { + background: white; + } + + .news_event:nth-of-type(even) { + background: $primary-neutral-light-color; + } + + .news_event { + display: block; + padding: 0.4em; + + &:not(:last-child) { + border-bottom: 1px solid grey; + } + + div { + margin: 0.2em; + } + + h4 { + margin-top: 1em; + text-transform: uppercase; + } + + .club_logo { + float: left; + min-width: 7em; + max-width: 9em; + margin: 0; + margin-right: 1em; + margin-top: 0.8em; + + img { + max-height: 6em; + max-width: 8em; + display: block; + margin: 0 auto; + } + } + + .news_date { + font-size: 100%; + } + + .news_content { + clear: left; + + .button_bar { + text-align: right; + + .fb { + color: $faceblue; + } + + .twitter { + color: $twitblue; + } + } + } + } + } + } + + /* END EVENTS TODAY AND NEXT FEW DAYS */ + + /* COMING SOON */ + .news_coming_soon { + display: list-item; + list-style-type: square; + list-style-position: inside; + margin-left: 1em; + padding-left: 0; + + a { + font-weight: bold; + text-transform: uppercase; + } + + .news_date { + font-size: 0.9em; + } + } + + /* END COMING SOON */ + + /* NOTICES */ + .news_notice { + margin: 0 0 1em 1em; + padding: 0.4em; + padding-left: 1em; + background: $secondary-neutral-light-color; + box-shadow: $shadow-color 0 0 2px; + border-radius: 18px 5px 18px 5px; + + h4 { + margin: 0; + } + + .news_content { + margin-left: 1em; + } + } + + /* END NOTICES */ + + /* CALLS */ + .news_call { + margin: 0 0 1em 1em; + padding: 0.4em; + padding-left: 1em; + background: $secondary-neutral-light-color; + border: 1px solid grey; + box-shadow: $shadow-color 1px 1px 1px; + + h4 { + margin: 0; + } + + .news_date { + font-size: 0.9em; + } + + .news_content { + margin-left: 1em; + } + } + + /* END CALLS */ + + .news_empty { + margin-left: 1em; + } + + .news_date { + color: grey; + } +} \ No newline at end of file diff --git a/com/static/com/css/posters.scss b/com/static/com/css/posters.scss new file mode 100644 index 00000000..26cf2b91 --- /dev/null +++ b/com/static/com/css/posters.scss @@ -0,0 +1,230 @@ +#poster_list, +#screen_list, +#poster_edit, +#screen_edit { + position: relative; + + #title { + position: relative; + padding: 10px; + margin: 10px; + border-bottom: 2px solid black; + + h3 { + display: flex; + justify-content: center; + align-items: center; + } + + #links { + position: absolute; + display: flex; + bottom: 5px; + + &.left { + left: 0; + } + + &.right { + right: 0; + } + + .link { + padding: 5px; + padding-left: 20px; + padding-right: 20px; + margin-left: 5px; + border-radius: 20px; + background-color: hsl(40, 100%, 50%); + color: black; + + &:hover { + color: black; + background-color: hsl(40, 58%, 50%); + } + + &.delete { + background-color: hsl(0, 100%, 40%); + } + } + } + } + + #posters, + #screens { + position: relative; + display: flex; + flex-wrap: wrap; + + #no-posters, + #no-screens { + display: flex; + justify-content: center; + align-items: center; + } + + .poster, + .screen { + min-width: 10%; + max-width: 20%; + display: flex; + flex-direction: column; + margin: 10px; + border: 2px solid darkgrey; + border-radius: 4px; + padding: 10px; + background-color: lightgrey; + + * { + display: flex; + justify-content: center; + align-items: center; + } + + .name { + padding-bottom: 5px; + margin-bottom: 5px; + border-bottom: 1px solid whitesmoke; + } + + .image { + flex-grow: 1; + position: relative; + padding-bottom: 5px; + margin-bottom: 5px; + border-bottom: 1px solid whitesmoke; + + img { + max-height: 20vw; + max-width: 100%; + } + + &:hover { + &::before { + position: absolute; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + top: 0; + left: 0; + z-index: 10; + content: "Click to expand"; + color: white; + background-color: rgba(black, 0.5); + } + } + } + + .dates { + padding-bottom: 5px; + margin-bottom: 5px; + border-bottom: 1px solid whitesmoke; + + * { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + margin-left: 5px; + margin-right: 5px; + } + + .begin, + .end { + width: 48%; + } + + .begin { + border-right: 1px solid whitesmoke; + padding-right: 2%; + } + } + + .edit, + .moderate, + .slideshow { + padding: 5px; + border-radius: 20px; + background-color: hsl(40, 100%, 50%); + color: black; + + &:hover { + color: black; + background-color: hsl(40, 58%, 50%); + } + + &:nth-child(2n) { + margin-top: 5px; + margin-bottom: 5px; + } + } + + .tooltip { + visibility: hidden; + width: 120px; + background-color: hsl(210, 20%, 98%); + color: hsl(0, 0%, 0%); + text-align: center; + padding: 5px 0; + border-radius: 6px; + position: absolute; + z-index: 10; + + ul { + margin-left: 0; + display: inline-block; + + li { + display: list-item; + list-style-type: none; + } + } + } + + &.not_moderated { + border: 1px solid red; + } + + &:hover .tooltip { + visibility: visible; + } + } + } + + #view { + position: fixed; + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + top: 0; + left: 0; + z-index: 10; + visibility: hidden; + background-color: rgba(10, 10, 10, 0.9); + overflow: hidden; + + &.active { + visibility: visible; + } + + #placeholder { + width: 80vw; + height: 80vh; + display: flex; + justify-content: center; + align-items: center; + top: 0; + left: 0; + + img { + max-width: 100%; + max-height: 100%; + } + } + } +} \ No newline at end of file diff --git a/com/templates/com/news_detail.jinja b/com/templates/com/news_detail.jinja index cbfa596c..238515ed 100644 --- a/com/templates/com/news_detail.jinja +++ b/com/templates/com/news_detail.jinja @@ -11,6 +11,11 @@ {{ gen_news_metatags(news) }} {% endblock %} + +{% block additional_css %} + +{% endblock %} + {% block content %}

{% trans %}Back to news{% endtrans %}

diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index ac9a7892..3f2444f1 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -5,6 +5,15 @@ {% trans %}News{% endtrans %} {% endblock %} +{% block additional_css %} + + +{% endblock %} + +{% block additional_js %} + +{% endblock %} + {% block content %} {% if user.is_com_admin %}
@@ -98,16 +107,6 @@ type="EVENT").order_by('dates__start_date') %} {% endfor %} {% endif %} -

{% trans %}All coming events{% endtrans %}

- -
-
{% trans %}Agenda{% endtrans %}
@@ -154,8 +153,14 @@ type="EVENT").order_by('dates__start_date') %} {%- endif -%}
+ +

{% trans %}All coming events{% endtrans %}

+ + + + {% endblock %} diff --git a/com/templates/com/poster_list.jinja b/com/templates/com/poster_list.jinja index 8c4f5cd1..c9af62c0 100644 --- a/com/templates/com/poster_list.jinja +++ b/com/templates/com/poster_list.jinja @@ -10,6 +10,10 @@ {% trans %}Poster{% endtrans %} {% endblock %} +{% block additional_css %} + +{% endblock %} + {% block content %}
diff --git a/com/templates/com/poster_moderate.jinja b/com/templates/com/poster_moderate.jinja index 36e3dae7..6370becf 100644 --- a/com/templates/com/poster_moderate.jinja +++ b/com/templates/com/poster_moderate.jinja @@ -5,6 +5,10 @@ {% endblock %} +{% block additional_css %} + +{% endblock %} + {% block content %}
diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 9cf9c59b..222cc509 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -738,7 +738,7 @@ Welcome to the wiki page! NewsDate( news=n, start_date=friday + timedelta(hours=24 * 7 + 1), - end_date=self.now + timedelta(hours=24 * 7 + 9), + end_date=friday + timedelta(hours=24 * 7 + 9), ) ) # Weekly @@ -765,7 +765,7 @@ Welcome to the wiki page! ) NewsDate.objects.bulk_create(news_dates) - # Create som data for pedagogy + # Create some data for pedagogy UV( code="PA00", diff --git a/core/static/core/devices.scss b/core/static/core/devices.scss new file mode 100644 index 00000000..25839f24 --- /dev/null +++ b/core/static/core/devices.scss @@ -0,0 +1,5 @@ +/*--------------------------MEDIA QUERY HELPERS------------------------*/ + +$small-devices: 576px; +$medium-devices: 768px; +$large-devices: 992px; \ No newline at end of file diff --git a/core/static/core/style.scss b/core/static/core/style.scss index a9205e23..2f3af9f7 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -1,10 +1,6 @@ @import "colors"; @import "forms"; - -/*--------------------------MEDIA QUERY HELPERS------------------------*/ -$small-devices: 576px; -$medium-devices: 768px; -$large-devices: 992px; +@import "devices"; /*--------------------------------GENERAL------------------------------*/ @@ -453,302 +449,6 @@ body { } } - /*---------------------------------NEWS--------------------------------*/ - #news { - display: flex; - - @media (max-width: 800px) { - flex-direction: column; - } - - .news_column { - display: inline-block; - margin: 0; - vertical-align: top; - } - - #news_admin { - margin-bottom: 1em; - } - - #right_column { - flex: 20%; - float: right; - margin: 0.2em; - } - - #left_column { - flex: 79%; - margin: 0.2em; - - h3 { - background: $second-color; - box-shadow: $shadow-color 1px 1px 1px; - padding: 0.4em; - margin: 0 0 0.5em 0; - text-transform: uppercase; - font-size: 1.1em; - - &:not(:first-of-type) { - margin: 2em 0 1em 0; - } - } - } - - @media screen and (max-width: $small-devices) { - - #left_column, - #right_column { - flex: 100%; - } - } - - /* AGENDA/BIRTHDAYS */ - #agenda, - #birthdays { - display: block; - width: 100%; - background: white; - font-size: 70%; - margin-bottom: 1em; - - #agenda_title, - #birthdays_title { - margin: 0; - border-radius: 5px 5px 0 0; - box-shadow: $shadow-color 1px 1px 1px; - padding: 0.5em; - font-weight: bold; - font-size: 150%; - text-align: center; - text-transform: uppercase; - background: $second-color; - } - - #agenda_content { - overflow: auto; - box-shadow: $shadow-color 1px 1px 1px; - height: 20em; - } - - #agenda_content, - #birthdays_content { - .agenda_item { - padding: 0.5em; - margin-bottom: 0.5em; - - &:nth-of-type(even) { - background: $secondary-neutral-light-color; - } - - .agenda_time { - font-size: 90%; - color: grey; - } - - .agenda_item_content { - p { - margin-top: 0.2em; - } - } - } - - ul.birthdays_year { - margin: 0; - list-style-type: none; - font-weight: bold; - - >li { - padding: 0.5em; - - &:nth-child(even) { - background: $secondary-neutral-light-color; - } - } - - ul { - margin: 0; - margin-left: 1em; - list-style-type: square; - list-style-position: inside; - font-weight: normal; - } - } - } - } - - /* END AGENDA/BIRTHDAYS */ - - /* EVENTS TODAY AND NEXT FEW DAYS */ - .news_events_group { - box-shadow: $shadow-color 1px 1px 1px; - margin-left: 1em; - margin-bottom: 0.5em; - - .news_events_group_date { - display: table-cell; - padding: 0.6em; - vertical-align: middle; - background: $primary-neutral-dark-color; - color: $white-color; - text-transform: uppercase; - text-align: center; - font-weight: bold; - font-family: monospace; - font-size: 1.4em; - border-radius: 7px 0 0 7px; - - div { - margin: 0 auto; - - .day { - font-size: 1.5em; - } - } - } - - .news_events_group_items { - display: table-cell; - width: 100%; - - .news_event:nth-of-type(odd) { - background: white; - } - - .news_event:nth-of-type(even) { - background: $primary-neutral-light-color; - } - - .news_event { - display: block; - padding: 0.4em; - - &:not(:last-child) { - border-bottom: 1px solid grey; - } - - div { - margin: 0.2em; - } - - h4 { - margin-top: 1em; - text-transform: uppercase; - } - - .club_logo { - float: left; - min-width: 7em; - max-width: 9em; - margin: 0; - margin-right: 1em; - margin-top: 0.8em; - - img { - max-height: 6em; - max-width: 8em; - display: block; - margin: 0 auto; - } - } - - .news_date { - font-size: 100%; - } - - .news_content { - clear: left; - - .button_bar { - text-align: right; - - .fb { - color: $faceblue; - } - - .twitter { - color: $twitblue; - } - } - } - } - } - } - - /* END EVENTS TODAY AND NEXT FEW DAYS */ - - /* COMING SOON */ - .news_coming_soon { - display: list-item; - list-style-type: square; - list-style-position: inside; - margin-left: 1em; - padding-left: 0; - - a { - font-weight: bold; - text-transform: uppercase; - } - - .news_date { - font-size: 0.9em; - } - } - - /* END COMING SOON */ - - /* NOTICES */ - .news_notice { - margin: 0 0 1em 1em; - padding: 0.4em; - padding-left: 1em; - background: $secondary-neutral-light-color; - box-shadow: $shadow-color 0 0 2px; - border-radius: 18px 5px 18px 5px; - - h4 { - margin: 0; - } - - .news_content { - margin-left: 1em; - } - } - - /* END NOTICES */ - - /* CALLS */ - .news_call { - margin: 0 0 1em 1em; - padding: 0.4em; - padding-left: 1em; - background: $secondary-neutral-light-color; - border: 1px solid grey; - box-shadow: $shadow-color 1px 1px 1px; - - h4 { - margin: 0; - } - - .news_date { - font-size: 0.9em; - } - - .news_content { - margin-left: 1em; - } - } - - /* END CALLS */ - - .news_empty { - margin-left: 1em; - } - - .news_date { - color: grey; - } - } } @media screen and (max-width: $small-devices) { @@ -757,304 +457,6 @@ body { } } -#news_details { - display: inline-block; - margin-top: 20px; - padding: 0.4em; - width: 80%; - background: $white-color; - - h4 { - margin-top: 1em; - text-transform: uppercase; - } - - .club_logo { - display: inline-block; - text-align: center; - width: 19%; - float: left; - min-width: 15em; - margin: 0; - - img { - max-height: 15em; - max-width: 12em; - display: block; - margin: 0 auto; - margin-bottom: 10px; - } - } - - .share_button { - border: none; - color: white; - padding: 0.5em 1em; - text-align: center; - text-decoration: none; - font-size: 1.2em; - border-radius: 2px; - float: right; - display: block; - margin-left: 0.3em; - - &:hover { - color: lightgrey; - } - } - - .facebook { - background: $faceblue; - } - - .twitter { - background: $twitblue; - } - - .news_meta { - margin-top: 10em; - font-size: small; - } -} - -.helptext { - margin-top: 10px; - display: block; -} - -/*---------------------------POSTERS----------------------------*/ - -#poster_list, -#screen_list, -#poster_edit, -#screen_edit { - position: relative; - - #title { - position: relative; - padding: 10px; - margin: 10px; - border-bottom: 2px solid black; - - h3 { - display: flex; - justify-content: center; - align-items: center; - } - - #links { - position: absolute; - display: flex; - bottom: 5px; - - &.left { - left: 0; - } - - &.right { - right: 0; - } - - .link { - padding: 5px; - padding-left: 20px; - padding-right: 20px; - margin-left: 5px; - border-radius: 20px; - background-color: hsl(40, 100%, 50%); - color: black; - - &:hover { - color: black; - background-color: hsl(40, 58%, 50%); - } - - &.delete { - background-color: hsl(0, 100%, 40%); - } - } - } - } - - #posters, - #screens { - position: relative; - display: flex; - flex-wrap: wrap; - - #no-posters, - #no-screens { - display: flex; - justify-content: center; - align-items: center; - } - - .poster, - .screen { - min-width: 10%; - max-width: 20%; - display: flex; - flex-direction: column; - margin: 10px; - border: 2px solid darkgrey; - border-radius: 4px; - padding: 10px; - background-color: lightgrey; - - * { - display: flex; - justify-content: center; - align-items: center; - } - - .name { - padding-bottom: 5px; - margin-bottom: 5px; - border-bottom: 1px solid whitesmoke; - } - - .image { - flex-grow: 1; - position: relative; - padding-bottom: 5px; - margin-bottom: 5px; - border-bottom: 1px solid whitesmoke; - - img { - max-height: 20vw; - max-width: 100%; - } - - &:hover { - &::before { - position: absolute; - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; - flex-wrap: wrap; - top: 0; - left: 0; - z-index: 10; - content: "Click to expand"; - color: white; - background-color: rgba(black, 0.5); - } - } - } - - .dates { - padding-bottom: 5px; - margin-bottom: 5px; - border-bottom: 1px solid whitesmoke; - - * { - display: flex; - justify-content: center; - align-items: center; - flex-wrap: wrap; - margin-left: 5px; - margin-right: 5px; - } - - .begin, - .end { - width: 48%; - } - - .begin { - border-right: 1px solid whitesmoke; - padding-right: 2%; - } - } - - .edit, - .moderate, - .slideshow { - padding: 5px; - border-radius: 20px; - background-color: hsl(40, 100%, 50%); - color: black; - - &:hover { - color: black; - background-color: hsl(40, 58%, 50%); - } - - &:nth-child(2n) { - margin-top: 5px; - margin-bottom: 5px; - } - } - - .tooltip { - visibility: hidden; - width: 120px; - background-color: hsl(210, 20%, 98%); - color: hsl(0, 0%, 0%); - text-align: center; - padding: 5px 0; - border-radius: 6px; - position: absolute; - z-index: 10; - - ul { - margin-left: 0; - display: inline-block; - - li { - display: list-item; - list-style-type: none; - } - } - } - - &.not_moderated { - border: 1px solid red; - } - - &:hover .tooltip { - visibility: visible; - } - } - } - - #view { - position: fixed; - width: 100vw; - height: 100vh; - display: flex; - justify-content: center; - align-items: center; - top: 0; - left: 0; - z-index: 10; - visibility: hidden; - background-color: rgba(10, 10, 10, 0.9); - overflow: hidden; - - &.active { - visibility: visible; - } - - #placeholder { - width: 80vw; - height: 80vh; - display: flex; - justify-content: center; - align-items: center; - top: 0; - left: 0; - - img { - max-width: 100%; - max-height: 100%; - } - } - } -} - /*---------------------------ACCOUNTING----------------------------*/ #accounting { .journal-table { diff --git a/core/templates/core/poster_list.jinja b/core/templates/core/poster_list.jinja deleted file mode 100644 index fe65658c..00000000 --- a/core/templates/core/poster_list.jinja +++ /dev/null @@ -1,54 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block script %} - {{ super() }} - -{% endblock %} - - -{% block title %} - {% trans %}Poster{% endtrans %} -{% endblock %} - -{% block content %} -
- -
-

{% trans %}Posters{% endtrans %}

- -
- -
- - {% if poster_list.count() == 0 %} -
{% trans %}No posters{% endtrans %}
- {% else %} - - {% for poster in poster_list %} -
-
{{ poster.name }}
-
-
-
{{ poster.date_begin | date("d/M/Y H:m") }}
-
{{ poster.date_end | date("d/M/Y H:m") }}
-
- {% trans %}Edit{% endtrans %} -
- {% endfor %} - - {% endif %} - -
- -
- -
-{% endblock %} - - - diff --git a/package-lock.json b/package-lock.json index 9b49ac0e..bfa05f40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,10 @@ "dependencies": { "@alpinejs/sort": "^3.14.7", "@fortawesome/fontawesome-free": "^6.6.0", + "@fullcalendar/core": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/icalendar": "^6.1.15", + "@fullcalendar/list": "^6.1.15", "@hey-api/client-fetch": "^0.4.0", "@sentry/browser": "^8.34.0", "@zip.js/zip.js": "^2.7.52", @@ -2384,6 +2388,39 @@ "node": ">=6" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.15.tgz", + "integrity": "sha512-BuX7o6ALpLb84cMw1FCB9/cSgF4JbVO894cjJZ6kP74jzbUZNjtwffwRdA+Id8rrLjT30d/7TrkW90k4zbXB5Q==", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.15.tgz", + "integrity": "sha512-j8tL0HhfiVsdtOCLfzK2J0RtSkiad3BYYemwQKq512cx6btz6ZZ2RNc/hVnIxluuWFyvx5sXZwoeTJsFSFTEFA==", + "peerDependencies": { + "@fullcalendar/core": "~6.1.15" + } + }, + "node_modules/@fullcalendar/icalendar": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/icalendar/-/icalendar-6.1.15.tgz", + "integrity": "sha512-iroDc02fjxWCEYE9Lg8x+4HCJTrt04ZgDddwm0LLaWUbtx24rEcnzJP34NUx0KOTLsBjel6U/33lXvU9qDCrhg==", + "peerDependencies": { + "@fullcalendar/core": "~6.1.15", + "ical.js": "^1.4.0" + } + }, + "node_modules/@fullcalendar/list": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.15.tgz", + "integrity": "sha512-U1bce04tYDwkFnuVImJSy2XalYIIQr6YusOWRPM/5ivHcJh67Gm8CIMSWpi3KdRSNKFkqBxLPkfZGBMaOcJYug==", + "peerDependencies": { + "@fullcalendar/core": "~6.1.15" + } + }, "node_modules/@hey-api/client-fetch": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.4.0.tgz", @@ -4162,6 +4199,12 @@ "node": ">=16.17.0" } }, + "node_modules/ical.js": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-1.5.0.tgz", + "integrity": "sha512-7ZxMkogUkkaCx810yp0ZGKvq1ZpRgJeornPttpoxe6nYZ3NLesZe1wWMXDdwTkj/b5NtXT+Y16Aakph/ao98ZQ==", + "peer": true + }, "node_modules/import-from-esm": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.4.tgz", @@ -4924,6 +4967,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 9721eea4..379fc782 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "#openapi": "./staticfiles/generated/openapi/index.ts", "#core:*": "./core/static/bundled/*", "#pedagogy:*": "./pedagogy/static/bundled/*", - "#counter:*": "./counter/static/bundled/*" + "#counter:*": "./counter/static/bundled/*", + "#com:*": "./com/static/bundled/*" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -36,6 +37,10 @@ "dependencies": { "@alpinejs/sort": "^3.14.7", "@fortawesome/fontawesome-free": "^6.6.0", + "@fullcalendar/core": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/icalendar": "^6.1.15", + "@fullcalendar/list": "^6.1.15", "@hey-api/client-fetch": "^0.4.0", "@sentry/browser": "^8.34.0", "@zip.js/zip.js": "^2.7.52", diff --git a/poetry.lock b/poetry.lock index 311df18a..3c4c08b0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -22,6 +22,25 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "arrow" +version = "1.3.0" +description = "Better dates & times for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"}, + {file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"}, +] + +[package.dependencies] +python-dateutil = ">=2.7.0" +types-python-dateutil = ">=2.8.10" + +[package.extras] +doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] +test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] + [[package]] name = "asgiref" version = "3.8.1" @@ -51,6 +70,25 @@ files = [ astroid = ["astroid (>=2,<4)"] test = ["astroid (>=2,<4)", "pytest", "pytest-cov", "pytest-xdist"] +[[package]] +name = "attrs" +version = "24.3.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.8" +files = [ + {file = "attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308"}, + {file = "attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + [[package]] name = "babel" version = "2.16.0" @@ -931,6 +969,24 @@ files = [ {file = "hiredis-3.1.0.tar.gz", hash = "sha256:51d40ac3611091020d7dea6b05ed62cb152bff595fa4f931e7b6479d777acf7c"}, ] +[[package]] +name = "ics" +version = "0.7.2" +description = "Python icalendar (rfc5545) parser" +optional = false +python-versions = "*" +files = [ + {file = "ics-0.7.2-py2.py3-none-any.whl", hash = "sha256:5fcf4d29ec6e7dfcb84120abd617bbba632eb77b097722b7df70e48dbcf26103"}, + {file = "ics-0.7.2.tar.gz", hash = "sha256:6743539bca10391635249b87d74fcd1094af20b82098bebf7c7521df91209f05"}, +] + +[package.dependencies] +arrow = ">=0.11" +attrs = ">=19.1.0" +python-dateutil = "*" +six = ">1.5" +tatsu = ">4.2" + [[package]] name = "identify" version = "2.6.3" @@ -2524,6 +2580,21 @@ pure-eval = "*" [package.extras] tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] +[[package]] +name = "tatsu" +version = "5.12.2" +description = "TatSu takes a grammar in a variation of EBNF as input, and outputs a memoizing PEG/Packrat parser in Python." +optional = false +python-versions = ">=3.11" +files = [ + {file = "TatSu-5.12.2-py3-none-any.whl", hash = "sha256:9c313186ae5262662cb3fbec52c9a12db1ef752e615f46cac3eb568cb91eacf9"}, + {file = "tatsu-5.12.2.tar.gz", hash = "sha256:5894dc7ddba9a1886a95ff2f06cef1be2b3d3a37c776eba8177ef4dcd80ccb03"}, +] + +[package.extras] +colorization = ["colorama"] +parproc = ["rich"] + [[package]] name = "tomli" version = "2.2.1" @@ -2580,6 +2651,17 @@ files = [ docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20241206" +description = "Typing stubs for python-dateutil" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53"}, + {file = "types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb"}, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -2724,4 +2806,4 @@ filelock = ">=3.4" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "5836c1a8ad42645d7d045194c8c371754b19957ebdcd2aaa902a2fb3dc97cc53" +content-hash = "f0acbbe66fd99ac04891bcc8a5f28167a927e0b1f3677ebd8ab302a0e2fb9be2" diff --git a/pyproject.toml b/pyproject.toml index be892cdf..f3427faf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ Sphinx = "^5" # Needed for building xapian tomli = "^2.2.1" django-honeypot = "^1.2.1" pydantic-extra-types = "^2.10.1" +ics = "^0.7.2" [tool.poetry.group.prod.dependencies] # deps used in prod, but unnecessary for development diff --git a/sith/settings.py b/sith/settings.py index 5fdc3786..a88734d3 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -163,6 +163,7 @@ TEMPLATES = [ "ProductType": "counter.models.ProductType", "timezone": "django.utils.timezone", "get_sith": "com.views.sith", + "get_language": "django.utils.translation.get_language", }, "bytecode_cache": { "name": "default", diff --git a/tsconfig.json b/tsconfig.json index 7b3be5fc..aaee9330 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,8 @@ "#openapi": ["./staticfiles/generated/openapi/index.ts"], "#core:*": ["./core/static/bundled/*"], "#pedagogy:*": ["./pedagogy/static/bundled/*"], - "#counter:*": ["./counter/static/bundled/*"] + "#counter:*": ["./counter/static/bundled/*"], + "#com:*": ["./com/static/bundled/*"] } } } From 6d7467e746989c0a9be62a55f93e6d09b38ada89 Mon Sep 17 00:00:00 2001 From: Sli Date: Mon, 30 Dec 2024 14:41:41 +0100 Subject: [PATCH 03/20] Make new calendar look like the iframe one --- com/static/com/components/ics-calendar.scss | 39 +++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss index bb858dd5..25a66c34 100644 --- a/com/static/com/components/ics-calendar.scss +++ b/com/static/com/components/ics-calendar.scss @@ -44,12 +44,12 @@ --sc-past-day-background-color: rgba(255, 255, 255, 0); --sc-future-day-background-color: rgba(255, 255, 255, 0); --sc-disabled-day-background-color: rgba(208, 208, 208, 0.3); - --sc-event-overlay-background-color: #FFFFFF; + --sc-event-overlay-background-color: white; --sc-event-overlay-padding: 20px; --sc-event-overlay-border: 1px solid #EEEEEE; --sc-event-overlay-border-radius: 4px; --sc-event-overlay-primary-icon-color: #1a78b3; - --sc-event-overlay-secondary-icon-color: #000000; + --sc-event-overlay-secondary-icon-color: black; --sc-event-overlay-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%); --sc-event-overlay-max-width: 600px; } @@ -57,4 +57,39 @@ ics-calendar { border: none; box-shadow: none; + + a.fc-col-header-cell-cushion, + a.fc-col-header-cell-cushion:hover { + color: black; + } + + a.fc-daygrid-day-number, + a.fc-daygrid-day-number:hover { + color: rgb(34, 34, 34); + } + + td { + overflow: visible; // Show events on multiple days + } + + //Reset from style.scss + table { + box-shadow: none; + border-radius: 0px; + -moz-border-radius: 0px; + margin: 0px; + } + + // Reset from style.scss + thead { + background-color: white; + color: black; + } + + // Reset from style.scss + tbody>tr { + &:nth-child(even):not(.highlight) { + background: white; + } + } } \ No newline at end of file From 48f6d134bf1ef975a00318e9be28433311ecea69 Mon Sep 17 00:00:00 2001 From: Sli Date: Mon, 30 Dec 2024 14:54:36 +0100 Subject: [PATCH 04/20] Fix news page layout --- com/templates/com/news_list.jinja | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index 3f2444f1..385cbe32 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -92,20 +92,12 @@
{% endif %} -{% set coming_soon = object_list.filter(dates__start_date__gte=timezone.now()+timedelta(days=5), -type="EVENT").order_by('dates__start_date') %} -{% if coming_soon %} -

{% trans %}Coming soon... don't miss!{% endtrans %}

- {% for news in coming_soon %} -
- {{ news.title }} - {{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} - - {{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }} -
- {% endfor %} -{% endif %} + +

{% trans %}All coming events{% endtrans %}

+ + + +
@@ -154,10 +146,6 @@ type="EVENT").order_by('dates__start_date') %}
-

{% trans %}All coming events{% endtrans %}

- - - From eac2709e86689641339c0f225daf9495221c9337 Mon Sep 17 00:00:00 2001 From: Sli Date: Tue, 31 Dec 2024 12:15:17 +0100 Subject: [PATCH 05/20] Create basic (ugly) event detail popup --- .../com/components/ics-calendar-index.ts | 80 ++++++++++++++++++- com/static/com/components/ics-calendar.scss | 80 +++++++++---------- 2 files changed, 117 insertions(+), 43 deletions(-) diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index f88b9b0f..130dd8ef 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -1,6 +1,6 @@ import { makeUrl } from "#core:utils/api"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; -import { Calendar } from "@fullcalendar/core"; +import { Calendar, type EventClickArg } from "@fullcalendar/core"; import enLocale from "@fullcalendar/core/locales/en-gb"; import frLocale from "@fullcalendar/core/locales/fr"; import dayGridPlugin from "@fullcalendar/daygrid"; @@ -46,6 +46,71 @@ export class IcsCalendar extends inheritHtmlElement("div") { }; } + formatDate(date: Date) { + return new Intl.DateTimeFormat(this.locale, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); + } + + createEventDetailPopup(event: EventClickArg) { + // Delete previous popup + const oldPopup = document.getElementById("event-details"); + if (oldPopup !== null) { + oldPopup.remove(); + } + + // Create new popup + const popup = document.createElement("div"); + const popupContainer = document.createElement("div"); + const popupFirstRow = document.createElement("div"); + const popupSecondRow = document.createElement("div"); + const popupTitleTimeIcon = document.createElement("i"); + const popupTitleTime = document.createElement("div"); + const popupTitle = document.createElement("h4"); + const popupTime = document.createElement("span"); + + popup.setAttribute("id", "event-details"); + popupContainer.setAttribute("class", "event-details-container"); + popupFirstRow.setAttribute("class", "event-details-row"); + popupSecondRow.setAttribute("class", "event-details-row"); + + popupTitleTimeIcon.setAttribute("class", "fa-solid fa-calendar-days fa-xl"); + + popupTitle.setAttribute("class", "event-details-title"); + popupTitle.textContent = event.event.title; + + popupTime.setAttribute("class", "event-details-time"); + popupTime.textContent = `${this.formatDate(event.event.start)} - ${this.formatDate(event.event.end)}`; + + popupTitleTime.appendChild(popupTitle); + popupTitleTime.appendChild(popupTime); + + popupFirstRow.appendChild(popupTitleTimeIcon); + popupSecondRow.appendChild(popupTitleTime); + + popupContainer.appendChild(popupFirstRow); + popupContainer.appendChild(popupSecondRow); + + popup.appendChild(popupContainer); + + // We can't just add the element relative to the one we want to appear under + // Otherwise, it either gets clipped by the boundaries of the calendar or resize cells + // Here, we create a popup outside the calendar that follows the clicked element + this.node.appendChild(popup); + const follow = (node: HTMLElement) => { + const rect = node.getBoundingClientRect(); + popup.setAttribute( + "style", + `top: calc(${rect.top + window.scrollY}px + ${rect.height}px); left: ${rect.left + window.scrollX}px;`, + ); + }; + follow(event.el); + window.addEventListener("resize", () => { + follow(event.el); + }); + } + async connectedCallback() { super.connectedCallback(); this.calendar = new Calendar(this.node, { @@ -69,7 +134,20 @@ export class IcsCalendar extends inheritHtmlElement("div") { this.calendar.changeView(this.currentView()); this.calendar.setOption("headerToolbar", this.currentToolbar()); }, + eventClick: (event) => { + // Avoid our popup to be deleted because we clicked outside of it + event.jsEvent.stopPropagation(); + this.createEventDetailPopup(event); + }, }); this.calendar.render(); + + window.addEventListener("click", (event: MouseEvent) => { + // Auto close popups when clicking outside of it + const popup = document.getElementById("event-details"); + if (popup !== null && !popup.contains(event.target as Node)) { + popup.remove(); + } + }); } } diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss index 25a66c34..825a7ebb 100644 --- a/com/static/com/components/ics-calendar.scss +++ b/com/static/com/components/ics-calendar.scss @@ -11,53 +11,49 @@ --fc-button-hover-bg-color: #15608F; --fc-today-bg-color: rgba(26, 120, 179, 0.1); --fc-border-color: #DDDDDD; - --sc-main-background-color: #f9fafb; - --sc-main-padding: 5px; - --sc-main-border: 0px solid #DDDDDD; - --sc-main-border-radius: 0px; - --sc-body-font-family: Roboto; - --sc-title-font-family: Roboto; - --sc-body-font-size: 16px; - --sc-title-font-size: 28px; - --sc-body-font-weight: 400; - --sc-title-font-weight: 500; - --sc-title-font-color: #111111; - --sc-base-body-font-color: #222222; - --sc-title-font-style: normal; - --sc-body-font-style: normal; - --sc-event-dot-color: #1a78b3; - --sc-button-border: 1px solid #ffffff; - --sc-button-border-radius: 4px; - --sc-button-icons-size: 22px; - --sc-grid-event-white-space: nowrap; - --sc-block-event-background-color-hovered: rgb(245, 245, 245); - --sc-block-event-border: 1px solid rgba(255, 255, 255, 0); - --sc-block-event-border-radius: 2.5px; - --sc-dot-event-background-color: rgba(255, 255, 255, 0); - --sc-dot-event-background-color-hovered: rgb(245, 245, 245); - --sc-dot-event-text-color: #222222; - --sc-dot-event-border: 1px solid rgba(255, 255, 255, 0); - --sc-dot-event-border-radius: 2.5px; - --sc-grid-day-header-background-color: rgba(255, 255, 255, 0); - --sc-list-day-header-background-color: rgba(208, 208, 208, 0.3); - --sc-inner-calendar-background-color: rgba(255, 255, 255, 0); - --sc-past-day-background-color: rgba(255, 255, 255, 0); - --sc-future-day-background-color: rgba(255, 255, 255, 0); - --sc-disabled-day-background-color: rgba(208, 208, 208, 0.3); - --sc-event-overlay-background-color: white; - --sc-event-overlay-padding: 20px; - --sc-event-overlay-border: 1px solid #EEEEEE; - --sc-event-overlay-border-radius: 4px; - --sc-event-overlay-primary-icon-color: #1a78b3; - --sc-event-overlay-secondary-icon-color: black; - --sc-event-overlay-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%); - --sc-event-overlay-max-width: 600px; + --event-details-background-color: white; + --event-details-padding: 20px; + --event-details-border: 1px solid #EEEEEE; + --event-details-border-radius: 4px; + --event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%); + --event-details-max-width: 600px; } ics-calendar { border: none; box-shadow: none; + #event-details { + z-index: 10; + max-width: 1151px; + position: absolute; + + .event-details-container { + display: flex; + color: black; + flex-direction: column; + min-width: 200px; + max-width: var(--event-details-max-width); + padding: var(--event-details-padding); + border: var(--event-details-border); + border-radius: var(--event-details-border-radius); + background-color: var(--event-details-background-color); + box-shadow: var(--event-details-box-shadow); + } + + .event-details-row { + display: flex; + flex-direction: row; + align-items: start; + } + + .event-details-title { + background-color: var(--event-details-background-color); + margin-top: 0px; + margin-bottom: 4px; + } + } + a.fc-col-header-cell-cushion, a.fc-col-header-cell-cushion:hover { color: black; @@ -69,7 +65,7 @@ ics-calendar { } td { - overflow: visible; // Show events on multiple days + overflow-x: visible; // Show events on multiple days } //Reset from style.scss From fd2295119d2e3f7fdf70272116ceed58c2c23f0b Mon Sep 17 00:00:00 2001 From: Sli Date: Tue, 31 Dec 2024 15:43:18 +0100 Subject: [PATCH 06/20] nice looking popup with well aligned icon --- .../bundled/com/components/ics-calendar-index.ts | 14 +++++++------- com/static/com/components/ics-calendar.scss | 13 +++++++++++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index 130dd8ef..6bf86111 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -64,7 +64,6 @@ export class IcsCalendar extends inheritHtmlElement("div") { const popup = document.createElement("div"); const popupContainer = document.createElement("div"); const popupFirstRow = document.createElement("div"); - const popupSecondRow = document.createElement("div"); const popupTitleTimeIcon = document.createElement("i"); const popupTitleTime = document.createElement("div"); const popupTitle = document.createElement("h4"); @@ -73,24 +72,25 @@ export class IcsCalendar extends inheritHtmlElement("div") { popup.setAttribute("id", "event-details"); popupContainer.setAttribute("class", "event-details-container"); popupFirstRow.setAttribute("class", "event-details-row"); - popupSecondRow.setAttribute("class", "event-details-row"); - popupTitleTimeIcon.setAttribute("class", "fa-solid fa-calendar-days fa-xl"); + popupTitleTimeIcon.setAttribute( + "class", + "fa-solid fa-calendar-days fa-xl event-detail-row-icon", + ); - popupTitle.setAttribute("class", "event-details-title"); + popupTitle.setAttribute("class", "event-details-row-content"); popupTitle.textContent = event.event.title; - popupTime.setAttribute("class", "event-details-time"); + popupTime.setAttribute("class", "event-details-row-content"); popupTime.textContent = `${this.formatDate(event.event.start)} - ${this.formatDate(event.event.end)}`; popupTitleTime.appendChild(popupTitle); popupTitleTime.appendChild(popupTime); popupFirstRow.appendChild(popupTitleTimeIcon); - popupSecondRow.appendChild(popupTitleTime); + popupFirstRow.appendChild(popupTitleTime); popupContainer.appendChild(popupFirstRow); - popupContainer.appendChild(popupSecondRow); popup.appendChild(popupContainer); diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss index 825a7ebb..1483cb2b 100644 --- a/com/static/com/components/ics-calendar.scss +++ b/com/static/com/components/ics-calendar.scss @@ -41,13 +41,22 @@ ics-calendar { box-shadow: var(--event-details-box-shadow); } + .event-detail-row-icon { + margin-left: 10px; + margin-right: 20px; + align-content: center; + align-self: center; + } + .event-details-row { display: flex; - flex-direction: row; align-items: start; } - .event-details-title { + .event-details-row-content { + display: flex; + align-items: start; + flex-direction: row; background-color: var(--event-details-background-color); margin-top: 0px; margin-bottom: 4px; From 9bd14f1b4edd5e35ca626a702496ad9197e304a6 Mon Sep 17 00:00:00 2001 From: Sli Date: Tue, 31 Dec 2024 15:54:50 +0100 Subject: [PATCH 07/20] Refactor popup creation --- .../com/components/ics-calendar-index.ts | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index 6bf86111..227694c0 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -1,6 +1,7 @@ import { makeUrl } from "#core:utils/api"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import { Calendar, type EventClickArg } from "@fullcalendar/core"; +import type { EventImpl } from "@fullcalendar/core/internal"; import enLocale from "@fullcalendar/core/locales/en-gb"; import frLocale from "@fullcalendar/core/locales/fr"; import dayGridPlugin from "@fullcalendar/daygrid"; @@ -60,37 +61,42 @@ export class IcsCalendar extends inheritHtmlElement("div") { oldPopup.remove(); } + const makePopupTitle = (event: EventImpl) => { + const row = document.createElement("div"); + const icon = document.createElement("i"); + const infoRow = document.createElement("div"); + const title = document.createElement("h4"); + const time = document.createElement("span"); + row.setAttribute("class", "event-details-row"); + + icon.setAttribute( + "class", + "fa-solid fa-calendar-days fa-xl event-detail-row-icon", + ); + + title.setAttribute("class", "event-details-row-content"); + title.textContent = event.title; + + time.setAttribute("class", "event-details-row-content"); + time.textContent = `${this.formatDate(event.start)} - ${this.formatDate(event.end)}`; + + infoRow.appendChild(title); + infoRow.appendChild(time); + + row.appendChild(icon); + row.appendChild(infoRow); + + return row; + }; + // Create new popup const popup = document.createElement("div"); const popupContainer = document.createElement("div"); - const popupFirstRow = document.createElement("div"); - const popupTitleTimeIcon = document.createElement("i"); - const popupTitleTime = document.createElement("div"); - const popupTitle = document.createElement("h4"); - const popupTime = document.createElement("span"); popup.setAttribute("id", "event-details"); popupContainer.setAttribute("class", "event-details-container"); - popupFirstRow.setAttribute("class", "event-details-row"); - popupTitleTimeIcon.setAttribute( - "class", - "fa-solid fa-calendar-days fa-xl event-detail-row-icon", - ); - - popupTitle.setAttribute("class", "event-details-row-content"); - popupTitle.textContent = event.event.title; - - popupTime.setAttribute("class", "event-details-row-content"); - popupTime.textContent = `${this.formatDate(event.event.start)} - ${this.formatDate(event.event.end)}`; - - popupTitleTime.appendChild(popupTitle); - popupTitleTime.appendChild(popupTime); - - popupFirstRow.appendChild(popupTitleTimeIcon); - popupFirstRow.appendChild(popupTitleTime); - - popupContainer.appendChild(popupFirstRow); + popupContainer.appendChild(makePopupTitle(event.event)); popup.appendChild(popupContainer); From e5fb87596831f42fcc8845a19b7e2fc8269b511a Mon Sep 17 00:00:00 2001 From: Sli Date: Tue, 31 Dec 2024 16:36:48 +0100 Subject: [PATCH 08/20] Add support for event location and more detail link --- com/api.py | 5 +- .../com/components/ics-calendar-index.ts | 64 +++++++++++++++---- com/static/com/components/ics-calendar.scss | 1 + 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/com/api.py b/com/api.py index 64f375fd..95e7fe92 100644 --- a/com/api.py +++ b/com/api.py @@ -3,6 +3,7 @@ from datetime import timedelta import urllib3 from django.core.cache import cache from django.http import HttpResponse +from django.urls import reverse from django.utils import timezone from ics import Calendar, Event from ninja_extra import ControllerBase, api_controller, route @@ -38,12 +39,14 @@ class CalendarController(ControllerBase): calendar = Calendar() for news_date in NewsDate.objects.filter( news__is_moderated=True, - start_date__lte=timezone.now() + timedelta(days=30), + end_date__gte=timezone.now() + - (timedelta(days=30) * 60), # Roughly get the last 6 months ).prefetch_related("news"): event = Event( name=news_date.news.title, begin=news_date.start_date, end=news_date.end_date, + url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}), ) calendar.events.add(event) diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index 227694c0..e3baddc6 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -61,18 +61,24 @@ export class IcsCalendar extends inheritHtmlElement("div") { oldPopup.remove(); } - const makePopupTitle = (event: EventImpl) => { + const makePopupInfo = (info: HTMLElement, iconClass: string) => { const row = document.createElement("div"); const icon = document.createElement("i"); - const infoRow = document.createElement("div"); - const title = document.createElement("h4"); - const time = document.createElement("span"); + row.setAttribute("class", "event-details-row"); - icon.setAttribute( - "class", - "fa-solid fa-calendar-days fa-xl event-detail-row-icon", - ); + icon.setAttribute("class", `event-detail-row-icon fa-xl ${iconClass}`); + + row.appendChild(icon); + row.appendChild(info); + + return row; + }; + + const makePopupTitle = (event: EventImpl) => { + const row = document.createElement("div"); + const title = document.createElement("h4"); + const time = document.createElement("span"); title.setAttribute("class", "event-details-row-content"); title.textContent = event.title; @@ -80,13 +86,33 @@ export class IcsCalendar extends inheritHtmlElement("div") { time.setAttribute("class", "event-details-row-content"); time.textContent = `${this.formatDate(event.start)} - ${this.formatDate(event.end)}`; - infoRow.appendChild(title); - infoRow.appendChild(time); + row.appendChild(title); + row.appendChild(time); + return makePopupInfo( + row, + "fa-solid fa-calendar-days fa-xl event-detail-row-icon", + ); + }; - row.appendChild(icon); - row.appendChild(infoRow); + const makePopupLocation = (event: EventImpl) => { + if (event.extendedProps.location === null) { + return null; + } + const info = document.createElement("div"); + info.innerText = event.extendedProps.location; - return row; + return makePopupInfo(info, "fa-solid fa-location-dot"); + }; + + const makePopupUrl = (event: EventImpl) => { + if (event.url === "") { + return null; + } + const url = document.createElement("a"); + url.href = event.url; + url.textContent = gettext("More info"); + + return makePopupInfo(url, "fa-solid fa-link"); }; // Create new popup @@ -98,6 +124,16 @@ export class IcsCalendar extends inheritHtmlElement("div") { popupContainer.appendChild(makePopupTitle(event.event)); + const location = makePopupLocation(event.event); + if (location !== null) { + popupContainer.appendChild(location); + } + + const url = makePopupUrl(event.event); + if (url !== null) { + popupContainer.appendChild(url); + } + popup.appendChild(popupContainer); // We can't just add the element relative to the one we want to appear under @@ -143,6 +179,8 @@ export class IcsCalendar extends inheritHtmlElement("div") { eventClick: (event) => { // Avoid our popup to be deleted because we clicked outside of it event.jsEvent.stopPropagation(); + // Don't auto-follow events URLs + event.jsEvent.preventDefault(); this.createEventDetailPopup(event); }, }); diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss index 1483cb2b..21aa55d7 100644 --- a/com/static/com/components/ics-calendar.scss +++ b/com/static/com/components/ics-calendar.scss @@ -39,6 +39,7 @@ ics-calendar { border-radius: var(--event-details-border-radius); background-color: var(--event-details-background-color); box-shadow: var(--event-details-box-shadow); + gap: 20px; } .event-detail-row-icon { From 169938e1da00e3d033f781b11d45c02c808b0248 Mon Sep 17 00:00:00 2001 From: Sli Date: Thu, 2 Jan 2025 02:29:41 +0100 Subject: [PATCH 09/20] Replace old agenda of event with links to services and change permission to see birthdays --- com/static/com/css/news-list.scss | 95 +++++++++++++------------------ com/templates/com/news_list.jinja | 63 ++++++++++++-------- com/tests.py | 17 ++++-- locale/fr/LC_MESSAGES/django.po | 73 ++++++++++++++---------- locale/fr/LC_MESSAGES/djangojs.po | 6 +- 5 files changed, 139 insertions(+), 115 deletions(-) diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss index a33e6315..4e6ca7a6 100644 --- a/com/static/com/css/news-list.scss +++ b/com/static/com/css/news-list.scss @@ -8,37 +8,33 @@ flex-direction: column; } - .news_column { - display: inline-block; - margin: 0; - vertical-align: top; - } - #news_admin { margin-bottom: 1em; } #right_column { flex: 20%; - float: right; - margin: 0.2em; + margin: 3.2px; + + display: inline-block; + vertical-align: top; } #left_column { flex: 79%; margin: 0.2em; + } - h3 { - background: $second-color; - box-shadow: $shadow-color 1px 1px 1px; - padding: 0.4em; - margin: 0 0 0.5em 0; - text-transform: uppercase; - font-size: 1.1em; + h3 { + background: $second-color; + box-shadow: $shadow-color 1px 1px 1px; + padding: 0.4em; + margin: 0 0 0.5em 0; + text-transform: uppercase; + font-size: 1.1em; - &:not(:first-of-type) { - margin: 2em 0 1em 0; - } + &:not(:first-of-type) { + margin: 2em 0 1em 0; } } @@ -50,8 +46,8 @@ } } - /* AGENDA/BIRTHDAYS */ - #agenda, + /* LINKS/BIRTHDAYS */ + #links, #birthdays { display: block; width: 100%; @@ -59,9 +55,7 @@ font-size: 70%; margin-bottom: 1em; - #agenda_title, - #birthdays_title { - margin: 0; + h3 { border-radius: 5px 5px 0 0; box-shadow: $shadow-color 1px 1px 1px; padding: 0.5em; @@ -72,34 +66,27 @@ background: $second-color; } - #agenda_content { + #links_content { overflow: auto; box-shadow: $shadow-color 1px 1px 1px; height: 20em; - } - #agenda_content, - #birthdays_content { - .agenda_item { - padding: 0.5em; - margin-bottom: 0.5em; - - &:nth-of-type(even) { - background: $secondary-neutral-light-color; - } - - .agenda_time { - font-size: 90%; - color: grey; - } - - .agenda_item_content { - p { - margin-top: 0.2em; - } - } + h4 { + margin-left: 5px; } + li { + margin: 10px; + } + + i { + width: 20px; + margin: 5px; + } + } + + + #birthdays_content { ul.birthdays_year { margin: 0; list-style-type: none; @@ -124,9 +111,9 @@ } } - /* END AGENDA/BIRTHDAYS */ + /* END AGENDA/BIRTHDAYS */ - /* EVENTS TODAY AND NEXT FEW DAYS */ + /* EVENTS TODAY AND NEXT FEW DAYS */ .news_events_group { box-shadow: $shadow-color 1px 1px 1px; margin-left: 1em; @@ -222,9 +209,9 @@ } } - /* END EVENTS TODAY AND NEXT FEW DAYS */ + /* END EVENTS TODAY AND NEXT FEW DAYS */ - /* COMING SOON */ + /* COMING SOON */ .news_coming_soon { display: list-item; list-style-type: square; @@ -242,9 +229,9 @@ } } - /* END COMING SOON */ + /* END COMING SOON */ - /* NOTICES */ + /* NOTICES */ .news_notice { margin: 0 0 1em 1em; padding: 0.4em; @@ -262,9 +249,9 @@ } } - /* END NOTICES */ + /* END NOTICES */ - /* CALLS */ + /* CALLS */ .news_call { margin: 0 0 1em 1em; padding: 0.4em; @@ -286,7 +273,7 @@ } } - /* END CALLS */ + /* END CALLS */ .news_empty { margin-left: 1em; diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index 385cbe32..28f998ca 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -99,35 +99,48 @@ -
-
-
{% trans %}Agenda{% endtrans %}
-
- {% for d in NewsDate.objects.filter(end_date__gte=timezone.now(), - news__is_moderated=True, news__type__in=["WEEKLY", - "EVENT"]).order_by('start_date', 'end_date') %} -
-
- {{ d.start_date|localtime|date('D d M Y') }} -
-
- {{ d.start_date|localtime|time(DATETIME_FORMAT) }} - - {{ d.end_date|localtime|time(DATETIME_FORMAT) }} -
- -
{{ d.news.summary|markdown }}
-
- {% endfor %} +
+
-
{% trans %}Birthdays{% endtrans %}
+

{% trans %}Birthdays{% endtrans %}

- {%- if user.is_subscribed -%} + {%- if user.was_subscribed -%}
    {%- for year, users in birthdays -%}
  • @@ -141,7 +154,7 @@ {%- endfor -%}
{%- else -%} -

{% trans %}You need an up to date subscription to access this content{% endtrans %}

+

{% trans %}You need to subscribe to access this content{% endtrans %}

{%- endif -%}
diff --git a/com/tests.py b/com/tests.py index 399eb0e8..3f98bfdc 100644 --- a/com/tests.py +++ b/com/tests.py @@ -97,9 +97,7 @@ class TestCom(TestCase): response = self.client.get(reverse("core:index")) self.assertContains( response, - text=html.escape( - _("You need an up to date subscription to access this content") - ), + text=html.escape(_("You need to subscribe to access this content")), ) def test_birthday_subscibed_user(self): @@ -107,9 +105,16 @@ class TestCom(TestCase): self.assertNotContains( response, - text=html.escape( - _("You need an up to date subscription to access this content") - ), + text=html.escape(_("You need to subscribe to access this content")), + ) + + def test_birthday_old_subscibed_user(self): + self.client.force_login(User.objects.get(username="old_subscriber")) + response = self.client.get(reverse("core:index")) + + self.assertNotContains( + response, + text=html.escape(_("You need to subscribe to access this content")), ) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index df9689e5..3ae57bdf 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-01-04 21:59+0100\n" +"POT-Creation-Date: 2025-01-04 23:05+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -356,9 +356,8 @@ msgstr "Nouveau compte club" #: com/templates/com/news_admin_list.jinja com/templates/com/poster_list.jinja #: com/templates/com/screen_list.jinja com/templates/com/weekmail.jinja #: core/templates/core/file.jinja core/templates/core/group_list.jinja -#: core/templates/core/page.jinja core/templates/core/poster_list.jinja -#: core/templates/core/user_tools.jinja core/views/user.py -#: counter/templates/counter/cash_summary_list.jinja +#: core/templates/core/page.jinja core/templates/core/user_tools.jinja +#: core/views/user.py counter/templates/counter/cash_summary_list.jinja #: counter/templates/counter/counter_list.jinja #: election/templates/election/election_detail.jinja #: forum/templates/forum/macros.jinja @@ -1140,7 +1139,7 @@ msgid "New Trombi" msgstr "Nouveau Trombi" #: club/templates/club/club_tools.jinja com/templates/com/poster_list.jinja -#: core/templates/core/poster_list.jinja core/templates/core/user_tools.jinja +#: core/templates/core/user_tools.jinja msgid "Posters" msgstr "Affiches" @@ -1558,17 +1557,46 @@ msgstr "Événements aujourd'hui et dans les prochains jours" msgid "Nothing to come..." msgstr "Rien à venir..." -#: com/templates/com/news_list.jinja -msgid "Coming soon... don't miss!" -msgstr "Prochainement... à ne pas rater!" - #: com/templates/com/news_list.jinja msgid "All coming events" msgstr "Tous les événements à venir" #: com/templates/com/news_list.jinja -msgid "Agenda" -msgstr "Agenda" +msgid "Links" +msgstr "Liens" + +#: com/templates/com/news_list.jinja +msgid "Our services" +msgstr "Nos services" + +#: com/templates/com/news_list.jinja pedagogy/templates/pedagogy/guide.jinja +msgid "UV Guide" +msgstr "Guide des UVs" + +#: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja +msgid "Matmatronch" +msgstr "Matmatronch" + +#: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja +#: core/templates/core/user_tools.jinja +msgid "Elections" +msgstr "Élections" + +#: com/templates/com/news_list.jinja +msgid "Social media" +msgstr "Réseaux sociaux" + +#: com/templates/com/news_list.jinja +msgid "Discord" +msgstr "Discord" + +#: com/templates/com/news_list.jinja +msgid "Facebook" +msgstr "Facebook" + +#: com/templates/com/news_list.jinja +msgid "Instagram" +msgstr "Instagram" #: com/templates/com/news_list.jinja msgid "Birthdays" @@ -1580,11 +1608,10 @@ msgid "%(age)s year old" msgstr "%(age)s ans" #: com/templates/com/news_list.jinja com/tests.py -msgid "You need an up to date subscription to access this content" -msgstr "Votre cotisation doit être à jour pour accéder à cette section" +msgid "You need to subscribe to access this content" +msgstr "Vous devez cotiser pour accéder à ce contenu" #: com/templates/com/poster_edit.jinja com/templates/com/poster_list.jinja -#: core/templates/core/poster_list.jinja msgid "Poster" msgstr "Affiche" @@ -1598,15 +1625,15 @@ msgid "Posters - edit" msgstr "Affiche - modifier" #: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja -#: core/templates/core/poster_list.jinja sas/templates/sas/main.jinja +#: sas/templates/sas/main.jinja msgid "Create" msgstr "Créer" -#: com/templates/com/poster_list.jinja core/templates/core/poster_list.jinja +#: com/templates/com/poster_list.jinja msgid "Moderation" msgstr "Modération" -#: com/templates/com/poster_list.jinja core/templates/core/poster_list.jinja +#: com/templates/com/poster_list.jinja msgid "No posters" msgstr "Aucune affiche" @@ -2233,10 +2260,6 @@ msgstr "Les clubs de L'AE" msgid "Others UTBM's Associations" msgstr "Les autres associations de l'UTBM" -#: core/templates/core/base/navbar.jinja core/templates/core/user_tools.jinja -msgid "Elections" -msgstr "Élections" - #: core/templates/core/base/navbar.jinja msgid "Big event" msgstr "Grandes Activités" @@ -2264,10 +2287,6 @@ msgstr "Eboutic" msgid "Services" msgstr "Services" -#: core/templates/core/base/navbar.jinja -msgid "Matmatronch" -msgstr "Matmatronch" - #: core/templates/core/base/navbar.jinja launderette/models.py #: launderette/templates/launderette/launderette_book.jinja #: launderette/templates/launderette/launderette_book_choose.jinja @@ -4859,10 +4878,6 @@ msgstr "signalant" msgid "reason" msgstr "raison" -#: pedagogy/templates/pedagogy/guide.jinja -msgid "UV Guide" -msgstr "Guide des UVs" - #: pedagogy/templates/pedagogy/guide.jinja #, python-format msgid "%(display_name)s" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 4c7c5dec..a8b7d40d 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-01-04 22:00+0100\n" +"POT-Creation-Date: 2025-01-04 23:07+0100\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -17,6 +17,10 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" +#: com/static/bundled/com/components/ics-calendar-index.ts +msgid "More info" +msgstr "Plus d'informations" + #: core/static/bundled/core/components/ajax-select-base.ts msgid "Remove" msgstr "Retirer" From a13e3e95b7fc1834f107aa481fd7223134a995f0 Mon Sep 17 00:00:00 2001 From: Sli Date: Thu, 2 Jan 2025 02:47:39 +0100 Subject: [PATCH 10/20] Harmonize titles on front page --- com/static/com/css/news-list.scss | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss index 4e6ca7a6..dcbad0b5 100644 --- a/com/static/com/css/news-list.scss +++ b/com/static/com/css/news-list.scss @@ -31,7 +31,7 @@ padding: 0.4em; margin: 0 0 0.5em 0; text-transform: uppercase; - font-size: 1.1em; + font-size: 17px; &:not(:first-of-type) { margin: 2em 0 1em 0; @@ -56,14 +56,7 @@ margin-bottom: 1em; h3 { - border-radius: 5px 5px 0 0; - box-shadow: $shadow-color 1px 1px 1px; - padding: 0.5em; - font-weight: bold; - font-size: 150%; - text-align: center; - text-transform: uppercase; - background: $second-color; + margin-bottom: 0; } #links_content { From 007080ee48e84d901f7ddd3a5f820574cd1778ee Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 3 Jan 2025 01:29:19 +0100 Subject: [PATCH 11/20] Extract send_file response creation logic to a dedicated function --- core/views/files.py | 59 ++++++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/core/views/files.py b/core/views/files.py index f8539080..d5ffabb6 100644 --- a/core/views/files.py +++ b/core/views/files.py @@ -13,6 +13,7 @@ # # import mimetypes +from pathlib import Path from urllib.parse import quote, urljoin # This file contains all the views that concern the page model @@ -48,6 +49,41 @@ from core.views.widgets.select import ( from counter.utils import is_logged_in_counter +def send_raw_file(path: Path) -> HttpResponse: + """Send a file located in the MEDIA_ROOT + + This handles all the logic of using production reverse proxy or debug server. + + THIS DOESN'T CHECK ANY PERMISSIONS ! + """ + if not path.is_relative_to(settings.MEDIA_ROOT): + raise Http404 + + if not path.is_file() or not path.exists(): + raise Http404 + + response = HttpResponse( + headers={"Content-Disposition": f'inline; filename="{quote(path.name)}"'} + ) + if not settings.DEBUG: + # When receiving a response with the Accel-Redirect header, + # the reverse proxy will automatically handle the file sending. + # This is really hard to test (thus isn't tested) + # so please do not mess with this. + response["Content-Type"] = "" # automatically set by nginx + response["X-Accel-Redirect"] = quote( + urljoin(settings.MEDIA_URL, str(path.relative_to(settings.MEDIA_ROOT))) + ) + return response + + with open(path, "rb") as filename: + response.content = FileWrapper(filename) + response["Content-Type"] = mimetypes.guess_type(path)[0] + response["Last-Modified"] = http_date(path.stat().st_mtime) + response["Content-Length"] = path.stat().st_size + return response + + def send_file( request: HttpRequest, file_id: int, @@ -66,28 +102,7 @@ def send_file( raise PermissionDenied name = getattr(f, file_attr).name - response = HttpResponse( - headers={"Content-Disposition": f'inline; filename="{quote(name)}"'} - ) - if not settings.DEBUG: - # When receiving a response with the Accel-Redirect header, - # the reverse proxy will automatically handle the file sending. - # This is really hard to test (thus isn't tested) - # so please do not mess with this. - response["Content-Type"] = "" # automatically set by nginx - response["X-Accel-Redirect"] = quote(urljoin(settings.MEDIA_URL, name)) - return response - - filepath = settings.MEDIA_ROOT / name - # check if file exists on disk - if not filepath.exists(): - raise Http404 - with open(filepath, "rb") as filename: - response.content = FileWrapper(filename) - response["Content-Type"] = mimetypes.guess_type(filepath)[0] - response["Last-Modified"] = http_date(f.date.timestamp()) - response["Content-Length"] = filepath.stat().st_size - return response + return send_raw_file(settings.MEDIA_ROOT / name) class MultipleFileInput(forms.ClearableFileInput): From 0a0f44607e2e0db0c1a4dbbcbd7cd0ff2a61af2e Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 3 Jan 2025 01:48:18 +0100 Subject: [PATCH 12/20] Return calendars as real files --- com/api.py | 50 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/com/api.py b/com/api.py index 95e7fe92..871b0eac 100644 --- a/com/api.py +++ b/com/api.py @@ -1,7 +1,8 @@ -from datetime import timedelta +from datetime import datetime, timedelta +from pathlib import Path import urllib3 -from django.core.cache import cache +from django.conf import settings from django.http import HttpResponse from django.urls import reverse from django.utils import timezone @@ -9,30 +10,37 @@ from ics import Calendar, Event from ninja_extra import ControllerBase, api_controller, route from com.models import NewsDate +from core.views.files import send_raw_file @api_controller("/calendar") class CalendarController(ControllerBase): + CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars" + @route.get("/external.ics") def calendar_external(self): - CACHE_KEY = "external_calendar" - if cached := cache.get(CACHE_KEY): - return HttpResponse( - cached, - content_type="text/calendar", - status=200, - ) + file = self.CACHE_FOLDER / "external.ics" + # Return cached file if updated less than an our ago + if ( + file.exists() + and timezone.make_aware(datetime.fromtimestamp(file.stat().st_mtime)) + + timedelta(hours=1) + > timezone.now() + ): + return send_raw_file(file) + calendar = urllib3.request( "GET", "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics", ) - if calendar.status == 200: - cache.set(CACHE_KEY, calendar.data, 3600) # Cache for one hour - return HttpResponse( - calendar.data, - content_type="text/calendar", - status=calendar.status, - ) + if calendar.status != 200: + return HttpResponse(status=calendar.status) + + self.CACHE_FOLDER.mkdir(parents=True, exist_ok=True) + with open(file, "wb") as f: + _ = f.write(calendar.data) + + return send_raw_file(file) @route.get("/internal.ics") def calendar_internal(self): @@ -50,7 +58,9 @@ class CalendarController(ControllerBase): ) calendar.events.add(event) - return HttpResponse( - calendar.serialize().encode("utf-8"), - content_type="text/calendar", - ) + # Create a file so we can offload the download to the reverse proxy if available + file = self.CACHE_FOLDER / "internal.ics" + self.CACHE_FOLDER.mkdir(parents=True, exist_ok=True) + with open(file, "wb") as f: + _ = f.write(calendar.serialize().encode("utf-8")) + return send_raw_file(file) From a60e1f1fdc4a32e8d84cf4ab1d896e9fb413938d Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 3 Jan 2025 13:36:39 +0100 Subject: [PATCH 13/20] Create dedicated class to manage ics calendar files --- com/api.py | 37 +++++++++++++------------------------ com/models.py | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/com/api.py b/com/api.py index 871b0eac..6aec227f 100644 --- a/com/api.py +++ b/com/api.py @@ -1,15 +1,14 @@ -from datetime import datetime, timedelta +from datetime import timedelta from pathlib import Path -import urllib3 from django.conf import settings -from django.http import HttpResponse +from django.http import Http404 from django.urls import reverse from django.utils import timezone from ics import Calendar, Event from ninja_extra import ControllerBase, api_controller, route -from com.models import NewsDate +from com.models import IcsCalendar, NewsDate from core.views.files import send_raw_file @@ -19,28 +18,18 @@ class CalendarController(ControllerBase): @route.get("/external.ics") def calendar_external(self): - file = self.CACHE_FOLDER / "external.ics" - # Return cached file if updated less than an our ago - if ( - file.exists() - and timezone.make_aware(datetime.fromtimestamp(file.stat().st_mtime)) - + timedelta(hours=1) - > timezone.now() - ): - return send_raw_file(file) + """Return the ICS file of the AE Google Calendar - calendar = urllib3.request( - "GET", - "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics", - ) - if calendar.status != 200: - return HttpResponse(status=calendar.status) + Because of Google's cors rules, we can't "just" do a request to google ics + from the frontend. Google is blocking CORS request in it's responses headers. + The only way to do it from the frontend is to use Google Calendar API with an API key + This is not especially desirable as your API key is going to be provided to the frontend. - self.CACHE_FOLDER.mkdir(parents=True, exist_ok=True) - with open(file, "wb") as f: - _ = f.write(calendar.data) - - return send_raw_file(file) + This is why we have this backend based solution. + """ + if (calendar := IcsCalendar.get_external()) is not None: + return send_raw_file(calendar) + raise Http404 @route.get("/internal.ics") def calendar_internal(self): diff --git a/com/models.py b/com/models.py index f3076174..6ec3ce53 100644 --- a/com/models.py +++ b/com/models.py @@ -17,11 +17,16 @@ # details. # # You should have received a copy of the GNU General Public License along with -# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple +# this program; if not, write to the Free Software Foundation, Inc., 59 Temple # Place - Suite 330, Boston, MA 02111-1307, USA. # # +from datetime import datetime, timedelta +from pathlib import Path +from typing import final + +import urllib3 from django.conf import settings from django.core.exceptions import ValidationError from django.core.mail import EmailMultiAlternatives @@ -37,6 +42,39 @@ from club.models import Club from core.models import Notification, Preferences, User +@final +class IcsCalendar: + _CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars" + _EXTERNAL_CALENDAR = _CACHE_FOLDER / "external.ics" + + @classmethod + def get_external(cls, expiration: timedelta = timedelta(hours=1)) -> Path | None: + if ( + cls._EXTERNAL_CALENDAR.exists() + and timezone.make_aware( + datetime.fromtimestamp(cls._EXTERNAL_CALENDAR.stat().st_mtime) + ) + + expiration + > timezone.now() + ): + return cls._EXTERNAL_CALENDAR + return cls.make_external() + + @classmethod + def make_external(cls) -> Path | None: + calendar = urllib3.request( + "GET", + "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics", + ) + if calendar.status != 200: + return None + + cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) + with open(cls._EXTERNAL_CALENDAR, "wb") as f: + _ = f.write(calendar.data) + return cls._EXTERNAL_CALENDAR + + class Sith(models.Model): """A one instance class storing all the modifiable infos.""" From 65df55a63520daa3faf8bfa98fc47bb53f0d18b3 Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 3 Jan 2025 13:56:40 +0100 Subject: [PATCH 14/20] Use signals to update internal ics --- com/api.py | 27 ++---------------------- com/apps.py | 9 ++++++++ com/models.py | 31 ++++++++++++++++++++++++++++ com/signals.py | 9 ++++++++ core/management/commands/populate.py | 3 ++- 5 files changed, 53 insertions(+), 26 deletions(-) create mode 100644 com/apps.py create mode 100644 com/signals.py diff --git a/com/api.py b/com/api.py index 6aec227f..63a0e680 100644 --- a/com/api.py +++ b/com/api.py @@ -1,14 +1,10 @@ -from datetime import timedelta from pathlib import Path from django.conf import settings from django.http import Http404 -from django.urls import reverse -from django.utils import timezone -from ics import Calendar, Event from ninja_extra import ControllerBase, api_controller, route -from com.models import IcsCalendar, NewsDate +from com.models import IcsCalendar from core.views.files import send_raw_file @@ -33,23 +29,4 @@ class CalendarController(ControllerBase): @route.get("/internal.ics") def calendar_internal(self): - calendar = Calendar() - for news_date in NewsDate.objects.filter( - news__is_moderated=True, - end_date__gte=timezone.now() - - (timedelta(days=30) * 60), # Roughly get the last 6 months - ).prefetch_related("news"): - event = Event( - name=news_date.news.title, - begin=news_date.start_date, - end=news_date.end_date, - url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}), - ) - calendar.events.add(event) - - # Create a file so we can offload the download to the reverse proxy if available - file = self.CACHE_FOLDER / "internal.ics" - self.CACHE_FOLDER.mkdir(parents=True, exist_ok=True) - with open(file, "wb") as f: - _ = f.write(calendar.serialize().encode("utf-8")) - return send_raw_file(file) + return send_raw_file(IcsCalendar.get_internal()) diff --git a/com/apps.py b/com/apps.py new file mode 100644 index 00000000..0502c588 --- /dev/null +++ b/com/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class ComConfig(AppConfig): + name = "com" + verbose_name = "News and communication" + + def ready(self): + import com.signals # noqa F401 diff --git a/com/models.py b/com/models.py index 6ec3ce53..c7042a38 100644 --- a/com/models.py +++ b/com/models.py @@ -37,6 +37,7 @@ from django.templatetags.static import static from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from ics import Calendar, Event from club.models import Club from core.models import Notification, Preferences, User @@ -46,6 +47,7 @@ from core.models import Notification, Preferences, User class IcsCalendar: _CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars" _EXTERNAL_CALENDAR = _CACHE_FOLDER / "external.ics" + _INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics" @classmethod def get_external(cls, expiration: timedelta = timedelta(hours=1)) -> Path | None: @@ -74,6 +76,35 @@ class IcsCalendar: _ = f.write(calendar.data) return cls._EXTERNAL_CALENDAR + @classmethod + def get_internal(cls) -> Path: + if not cls._INTERNAL_CALENDAR.exists(): + return cls.make_internal() + return cls._INTERNAL_CALENDAR + + @classmethod + def make_internal(cls) -> Path: + # Updated through a post_save signal on News in com.signals + calendar = Calendar() + for news_date in NewsDate.objects.filter( + news__is_moderated=True, + end_date__gte=timezone.now() + - (timedelta(days=30) * 60), # Roughly get the last 6 months + ).prefetch_related("news"): + event = Event( + name=news_date.news.title, + begin=news_date.start_date, + end=news_date.end_date, + url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}), + ) + calendar.events.add(event) + + # Create a file so we can offload the download to the reverse proxy if available + cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) + with open(cls._INTERNAL_CALENDAR, "wb") as f: + _ = f.write(calendar.serialize().encode("utf-8")) + return cls._INTERNAL_CALENDAR + class Sith(models.Model): """A one instance class storing all the modifiable infos.""" diff --git a/com/signals.py b/com/signals.py new file mode 100644 index 00000000..b67a4131 --- /dev/null +++ b/com/signals.py @@ -0,0 +1,9 @@ +from django.db.models.base import post_save +from django.dispatch import receiver + +from com.models import IcsCalendar, News + + +@receiver(post_save, sender=News, dispatch_uid="update_internal_ics") +def update_internal_ics(*args, **kwargs): + _ = IcsCalendar.make_internal() diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 222cc509..486f23cd 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -46,7 +46,7 @@ from accounting.models import ( SimplifiedAccountingType, ) from club.models import Club, Membership -from com.models import News, NewsDate, Sith, Weekmail +from com.models import IcsCalendar, News, NewsDate, Sith, Weekmail from core.models import Group, Page, PageRev, SithFile, User from core.utils import resize_image from counter.models import Counter, Product, ProductType, StudentCard @@ -764,6 +764,7 @@ Welcome to the wiki page! ] ) NewsDate.objects.bulk_create(news_dates) + IcsCalendar.make_internal() # Force refresh of the calendar after a bulk_create # Create some data for pedagogy From 5d0fc38107cb01d8a436c71a683c83f57999a528 Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 3 Jan 2025 14:38:09 +0100 Subject: [PATCH 15/20] Make social icons links pretty --- com/static/com/css/news-list.scss | 33 ++++++++++++++++++++++++------- com/templates/com/news_list.jinja | 2 +- core/static/core/colors.scss | 2 ++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss index dcbad0b5..bcbf8273 100644 --- a/com/static/com/css/news-list.scss +++ b/com/static/com/css/news-list.scss @@ -68,17 +68,36 @@ margin-left: 5px; } - li { - margin: 10px; + ul { + list-style: none; + margin-left: 0; + + li { + margin: 10px; + + .fa-facebook { + color: $faceblue; + } + + .fa-discord { + color: $discordblurple; + } + + .fa-square-instagram::before { + background: $instagradient; + background-clip: text; + -webkit-text-fill-color: transparent; + } + + i { + width: 25px; + text-align: center; + } + } } - i { - width: 20px; - margin: 5px; - } } - #birthdays_content { ul.birthdays_year { margin: 0; diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index 28f998ca..8f20ce19 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -130,7 +130,7 @@ {% trans %}Facebook{% endtrans %}
  • - + {% trans %}Instagram{% endtrans %}
  • diff --git a/core/static/core/colors.scss b/core/static/core/colors.scss index 35dc6a69..e10eb905 100644 --- a/core/static/core/colors.scss +++ b/core/static/core/colors.scss @@ -24,6 +24,8 @@ $black-color: hsl(0, 0%, 17%); $faceblue: hsl(221, 44%, 41%); $twitblue: hsl(206, 82%, 63%); +$discordblurple: #7289da; +$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%); $shadow-color: rgb(223, 223, 223); From 1887a2790f32b794a6b322c01a5ff8cc2ef4f15c Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 4 Jan 2025 18:57:31 +0100 Subject: [PATCH 16/20] Move IcsCalendar to it's own file --- com/api.py | 4 +- com/calendar.py | 74 ++++++++++++++++++++++++++++ com/models.py | 68 ------------------------- com/signals.py | 3 +- core/management/commands/populate.py | 3 +- 5 files changed, 80 insertions(+), 72 deletions(-) create mode 100644 com/calendar.py diff --git a/com/api.py b/com/api.py index 63a0e680..9a5b1398 100644 --- a/com/api.py +++ b/com/api.py @@ -4,7 +4,7 @@ from django.conf import settings from django.http import Http404 from ninja_extra import ControllerBase, api_controller, route -from com.models import IcsCalendar +from com.calendar import IcsCalendar from core.views.files import send_raw_file @@ -16,7 +16,7 @@ class CalendarController(ControllerBase): def calendar_external(self): """Return the ICS file of the AE Google Calendar - Because of Google's cors rules, we can't "just" do a request to google ics + Because of Google's cors rules, we can't just do a request to google ics from the frontend. Google is blocking CORS request in it's responses headers. The only way to do it from the frontend is to use Google Calendar API with an API key This is not especially desirable as your API key is going to be provided to the frontend. diff --git a/com/calendar.py b/com/calendar.py new file mode 100644 index 00000000..52cb25b9 --- /dev/null +++ b/com/calendar.py @@ -0,0 +1,74 @@ +from datetime import datetime, timedelta +from pathlib import Path +from typing import final + +import urllib3 +from django.conf import settings +from django.urls import reverse +from django.utils import timezone +from ics import Calendar, Event + +from com.models import NewsDate + + +@final +class IcsCalendar: + _CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars" + _EXTERNAL_CALENDAR = _CACHE_FOLDER / "external.ics" + _INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics" + + @classmethod + def get_external(cls, expiration: timedelta = timedelta(hours=1)) -> Path | None: + if ( + cls._EXTERNAL_CALENDAR.exists() + and timezone.make_aware( + datetime.fromtimestamp(cls._EXTERNAL_CALENDAR.stat().st_mtime) + ) + + expiration + > timezone.now() + ): + return cls._EXTERNAL_CALENDAR + return cls.make_external() + + @classmethod + def make_external(cls) -> Path | None: + calendar = urllib3.request( + "GET", + "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics", + ) + if calendar.status != 200: + return None + + cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) + with open(cls._EXTERNAL_CALENDAR, "wb") as f: + _ = f.write(calendar.data) + return cls._EXTERNAL_CALENDAR + + @classmethod + def get_internal(cls) -> Path: + if not cls._INTERNAL_CALENDAR.exists(): + return cls.make_internal() + return cls._INTERNAL_CALENDAR + + @classmethod + def make_internal(cls) -> Path: + # Updated through a post_save signal on News in com.signals + calendar = Calendar() + for news_date in NewsDate.objects.filter( + news__is_moderated=True, + end_date__gte=timezone.now() + - (timedelta(days=30) * 60), # Roughly get the last 6 months + ).prefetch_related("news"): + event = Event( + name=news_date.news.title, + begin=news_date.start_date, + end=news_date.end_date, + url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}), + ) + calendar.events.add(event) + + # Create a file so we can offload the download to the reverse proxy if available + cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) + with open(cls._INTERNAL_CALENDAR, "wb") as f: + _ = f.write(calendar.serialize().encode("utf-8")) + return cls._INTERNAL_CALENDAR diff --git a/com/models.py b/com/models.py index c7042a38..633c7671 100644 --- a/com/models.py +++ b/com/models.py @@ -22,11 +22,7 @@ # # -from datetime import datetime, timedelta -from pathlib import Path -from typing import final -import urllib3 from django.conf import settings from django.core.exceptions import ValidationError from django.core.mail import EmailMultiAlternatives @@ -37,75 +33,11 @@ from django.templatetags.static import static from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from ics import Calendar, Event from club.models import Club from core.models import Notification, Preferences, User -@final -class IcsCalendar: - _CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars" - _EXTERNAL_CALENDAR = _CACHE_FOLDER / "external.ics" - _INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics" - - @classmethod - def get_external(cls, expiration: timedelta = timedelta(hours=1)) -> Path | None: - if ( - cls._EXTERNAL_CALENDAR.exists() - and timezone.make_aware( - datetime.fromtimestamp(cls._EXTERNAL_CALENDAR.stat().st_mtime) - ) - + expiration - > timezone.now() - ): - return cls._EXTERNAL_CALENDAR - return cls.make_external() - - @classmethod - def make_external(cls) -> Path | None: - calendar = urllib3.request( - "GET", - "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics", - ) - if calendar.status != 200: - return None - - cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) - with open(cls._EXTERNAL_CALENDAR, "wb") as f: - _ = f.write(calendar.data) - return cls._EXTERNAL_CALENDAR - - @classmethod - def get_internal(cls) -> Path: - if not cls._INTERNAL_CALENDAR.exists(): - return cls.make_internal() - return cls._INTERNAL_CALENDAR - - @classmethod - def make_internal(cls) -> Path: - # Updated through a post_save signal on News in com.signals - calendar = Calendar() - for news_date in NewsDate.objects.filter( - news__is_moderated=True, - end_date__gte=timezone.now() - - (timedelta(days=30) * 60), # Roughly get the last 6 months - ).prefetch_related("news"): - event = Event( - name=news_date.news.title, - begin=news_date.start_date, - end=news_date.end_date, - url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}), - ) - calendar.events.add(event) - - # Create a file so we can offload the download to the reverse proxy if available - cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) - with open(cls._INTERNAL_CALENDAR, "wb") as f: - _ = f.write(calendar.serialize().encode("utf-8")) - return cls._INTERNAL_CALENDAR - - class Sith(models.Model): """A one instance class storing all the modifiable infos.""" diff --git a/com/signals.py b/com/signals.py index b67a4131..ea004ad8 100644 --- a/com/signals.py +++ b/com/signals.py @@ -1,7 +1,8 @@ from django.db.models.base import post_save from django.dispatch import receiver -from com.models import IcsCalendar, News +from com.calendar import IcsCalendar +from com.models import News @receiver(post_save, sender=News, dispatch_uid="update_internal_ics") diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 486f23cd..3ed1025d 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -46,7 +46,8 @@ from accounting.models import ( SimplifiedAccountingType, ) from club.models import Club, Membership -from com.models import IcsCalendar, News, NewsDate, Sith, Weekmail +from com.calendar import IcsCalendar +from com.models import News, NewsDate, Sith, Weekmail from core.models import Group, Page, PageRev, SithFile, User from core.utils import resize_image from counter.models import Counter, Product, ProductType, StudentCard From ba76015c71957a2a9a47e3671f0f369064e6d8d1 Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 4 Jan 2025 19:24:40 +0100 Subject: [PATCH 17/20] Use a newer ical library --- com/calendar.py | 16 ++++---- poetry.lock | 101 ++++++++++++------------------------------------ pyproject.toml | 2 +- 3 files changed, 35 insertions(+), 84 deletions(-) diff --git a/com/calendar.py b/com/calendar.py index 52cb25b9..9003d6de 100644 --- a/com/calendar.py +++ b/com/calendar.py @@ -3,10 +3,13 @@ from pathlib import Path from typing import final import urllib3 +from dateutil.relativedelta import relativedelta from django.conf import settings from django.urls import reverse from django.utils import timezone -from ics import Calendar, Event +from ical.calendar import Calendar +from ical.calendar_stream import IcsCalendarStream +from ical.event import Event from com.models import NewsDate @@ -56,19 +59,18 @@ class IcsCalendar: calendar = Calendar() for news_date in NewsDate.objects.filter( news__is_moderated=True, - end_date__gte=timezone.now() - - (timedelta(days=30) * 60), # Roughly get the last 6 months + end_date__gte=timezone.now() - (relativedelta(months=6)), ).prefetch_related("news"): event = Event( - name=news_date.news.title, - begin=news_date.start_date, + summary=news_date.news.title, + start=news_date.start_date, end=news_date.end_date, url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}), ) - calendar.events.add(event) + calendar.events.append(event) # Create a file so we can offload the download to the reverse proxy if available cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) with open(cls._INTERNAL_CALENDAR, "wb") as f: - _ = f.write(calendar.serialize().encode("utf-8")) + _ = f.write(IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8")) return cls._INTERNAL_CALENDAR diff --git a/poetry.lock b/poetry.lock index 3c4c08b0..695b4503 100644 --- a/poetry.lock +++ b/poetry.lock @@ -22,25 +22,6 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] -[[package]] -name = "arrow" -version = "1.3.0" -description = "Better dates & times for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"}, - {file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"}, -] - -[package.dependencies] -python-dateutil = ">=2.7.0" -types-python-dateutil = ">=2.8.10" - -[package.extras] -doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] -test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] - [[package]] name = "asgiref" version = "3.8.1" @@ -70,25 +51,6 @@ files = [ astroid = ["astroid (>=2,<4)"] test = ["astroid (>=2,<4)", "pytest", "pytest-cov", "pytest-xdist"] -[[package]] -name = "attrs" -version = "24.3.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.8" -files = [ - {file = "attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308"}, - {file = "attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff"}, -] - -[package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] - [[package]] name = "babel" version = "2.16.0" @@ -970,22 +932,21 @@ files = [ ] [[package]] -name = "ics" -version = "0.7.2" -description = "Python icalendar (rfc5545) parser" +name = "ical" +version = "8.3.0" +description = "Python iCalendar implementation (rfc 2445)" optional = false -python-versions = "*" +python-versions = ">=3.10" files = [ - {file = "ics-0.7.2-py2.py3-none-any.whl", hash = "sha256:5fcf4d29ec6e7dfcb84120abd617bbba632eb77b097722b7df70e48dbcf26103"}, - {file = "ics-0.7.2.tar.gz", hash = "sha256:6743539bca10391635249b87d74fcd1094af20b82098bebf7c7521df91209f05"}, + {file = "ical-8.3.0-py3-none-any.whl", hash = "sha256:606f2f561bd8b75cb726710dddbb20f3f84dfa1d6323550947dba97359423850"}, + {file = "ical-8.3.0.tar.gz", hash = "sha256:e277cc518cbb0132e6827c318c8ec3b379b125ebf0a2a44337f08795d5530937"}, ] [package.dependencies] -arrow = ">=0.11" -attrs = ">=19.1.0" -python-dateutil = "*" -six = ">1.5" -tatsu = ">4.2" +pydantic = ">=1.9.1" +pyparsing = ">=3.0.9" +python-dateutil = ">=2.8.2" +tzdata = ">=2023.3" [[package]] name = "identify" @@ -1939,6 +1900,20 @@ pyyaml = "*" [package.extras] extra = ["pygments (>=2.12)"] +[[package]] +name = "pyparsing" +version = "3.2.1" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pyparsing-3.2.1-py3-none-any.whl", hash = "sha256:506ff4f4386c4cec0590ec19e6302d3aedb992fdc02c761e90416f158dacf8e1"}, + {file = "pyparsing-3.2.1.tar.gz", hash = "sha256:61980854fd66de3a90028d679a954d5f2623e83144b5afe5ee86f43d762e5f0a"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + [[package]] name = "pytest" version = "8.3.4" @@ -2580,21 +2555,6 @@ pure-eval = "*" [package.extras] tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] -[[package]] -name = "tatsu" -version = "5.12.2" -description = "TatSu takes a grammar in a variation of EBNF as input, and outputs a memoizing PEG/Packrat parser in Python." -optional = false -python-versions = ">=3.11" -files = [ - {file = "TatSu-5.12.2-py3-none-any.whl", hash = "sha256:9c313186ae5262662cb3fbec52c9a12db1ef752e615f46cac3eb568cb91eacf9"}, - {file = "tatsu-5.12.2.tar.gz", hash = "sha256:5894dc7ddba9a1886a95ff2f06cef1be2b3d3a37c776eba8177ef4dcd80ccb03"}, -] - -[package.extras] -colorization = ["colorama"] -parproc = ["rich"] - [[package]] name = "tomli" version = "2.2.1" @@ -2651,17 +2611,6 @@ files = [ docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] -[[package]] -name = "types-python-dateutil" -version = "2.9.0.20241206" -description = "Typing stubs for python-dateutil" -optional = false -python-versions = ">=3.8" -files = [ - {file = "types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53"}, - {file = "types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb"}, -] - [[package]] name = "typing-extensions" version = "4.12.2" @@ -2806,4 +2755,4 @@ filelock = ">=3.4" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "f0acbbe66fd99ac04891bcc8a5f28167a927e0b1f3677ebd8ab302a0e2fb9be2" +content-hash = "7f348f74a05c27e29aaaf25a5584bba9b416f42c3f370db234dd69e5e10dc8df" diff --git a/pyproject.toml b/pyproject.toml index f3427faf..3d761bca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ Sphinx = "^5" # Needed for building xapian tomli = "^2.2.1" django-honeypot = "^1.2.1" pydantic-extra-types = "^2.10.1" -ics = "^0.7.2" +ical = "^8.3.0" [tool.poetry.group.prod.dependencies] # deps used in prod, but unnecessary for development From fa7f5d24b014af0bc0ddce45505e3960ec295c35 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 5 Jan 2025 01:04:11 +0100 Subject: [PATCH 18/20] Test external calendar api --- com/api.py | 4 +- com/tests/test_api.py | 54 +++++++++++++++++++++++++++ com/{tests.py => tests/test_views.py} | 0 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 com/tests/test_api.py rename com/{tests.py => tests/test_views.py} (100%) diff --git a/com/api.py b/com/api.py index 9a5b1398..e46daea9 100644 --- a/com/api.py +++ b/com/api.py @@ -12,7 +12,7 @@ from core.views.files import send_raw_file class CalendarController(ControllerBase): CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars" - @route.get("/external.ics") + @route.get("/external.ics", url_name="calendar_external") def calendar_external(self): """Return the ICS file of the AE Google Calendar @@ -27,6 +27,6 @@ class CalendarController(ControllerBase): return send_raw_file(calendar) raise Http404 - @route.get("/internal.ics") + @route.get("/internal.ics", url_name="calendar_internal") def calendar_internal(self): return send_raw_file(IcsCalendar.get_internal()) diff --git a/com/tests/test_api.py b/com/tests/test_api.py new file mode 100644 index 00000000..0a8f3f96 --- /dev/null +++ b/com/tests/test_api.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from django.conf import settings +from django.test.client import Client +from django.urls import reverse + +from com.calendar import IcsCalendar + + +@dataclass +class MockResponse: + status: int + value: str + + @property + def data(self): + return self.value.encode("utf8") + + +@pytest.mark.django_db +class TestExternalCalendar: + @pytest.fixture + def mock_request(self): + request = MagicMock() + with patch("urllib3.request", request): + yield request + + @pytest.fixture(autouse=True) + def clear_cache(self): + IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True) + + @pytest.mark.parametrize("error_code", [403, 404, 500]) + def test_fetch_error( + self, client: Client, mock_request: MagicMock, error_code: int + ): + mock_request.return_value = MockResponse(error_code, "not allowed") + assert client.get(reverse("api:calendar_external")).status_code == 404 + + def test_fetch_success(self, client: Client, mock_request: MagicMock): + external_response = MockResponse(200, "Definitely an ICS") + mock_request.return_value = external_response + response = client.get(reverse("api:calendar_external")) + assert response.status_code == 200 + redirect = Path(response.headers.get("X-Accel-Redirect", "")) + assert redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem) + out_file = settings.MEDIA_ROOT / redirect.relative_to( + Path("/") / settings.MEDIA_ROOT.stem + ) + assert out_file.exists() + with open(out_file, "r") as f: + assert f.read() == external_response.value diff --git a/com/tests.py b/com/tests/test_views.py similarity index 100% rename from com/tests.py rename to com/tests/test_views.py From eb3db134f86a9e30fb68cc523d9797292be26d8c Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 5 Jan 2025 01:32:54 +0100 Subject: [PATCH 19/20] Test external calendar caching --- com/tests/test_api.py | 70 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/com/tests/test_api.py b/com/tests/test_api.py index 0a8f3f96..cfc0e35f 100644 --- a/com/tests/test_api.py +++ b/com/tests/test_api.py @@ -1,11 +1,15 @@ from dataclasses import dataclass +from datetime import datetime, timedelta from pathlib import Path +from typing import Callable from unittest.mock import MagicMock, patch import pytest from django.conf import settings +from django.http import HttpResponse from django.test.client import Client from django.urls import reverse +from django.utils import timezone from com.calendar import IcsCalendar @@ -20,13 +24,29 @@ class MockResponse: return self.value.encode("utf8") +def accel_redirect_to_file(response: HttpResponse) -> Path | None: + redirect = Path(response.headers.get("X-Accel-Redirect", "")) + if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem): + return None + return settings.MEDIA_ROOT / redirect.relative_to( + Path("/") / settings.MEDIA_ROOT.stem + ) + + @pytest.mark.django_db class TestExternalCalendar: @pytest.fixture def mock_request(self): - request = MagicMock() - with patch("urllib3.request", request): - yield request + mock = MagicMock() + with patch("urllib3.request", mock): + yield mock + + @pytest.fixture + def mock_current_time(self): + mock = MagicMock() + original = timezone.now + with patch("django.utils.timezone.now", mock): + yield mock, original @pytest.fixture(autouse=True) def clear_cache(self): @@ -44,11 +64,45 @@ class TestExternalCalendar: mock_request.return_value = external_response response = client.get(reverse("api:calendar_external")) assert response.status_code == 200 - redirect = Path(response.headers.get("X-Accel-Redirect", "")) - assert redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem) - out_file = settings.MEDIA_ROOT / redirect.relative_to( - Path("/") / settings.MEDIA_ROOT.stem - ) + out_file = accel_redirect_to_file(response) + assert out_file is not None assert out_file.exists() with open(out_file, "r") as f: assert f.read() == external_response.value + + def test_fetch_caching( + self, + client: Client, + mock_request: MagicMock, + mock_current_time: tuple[MagicMock, Callable[[], datetime]], + ): + fake_current_time, original_timezone = mock_current_time + start_time = original_timezone() + + fake_current_time.return_value = start_time + external_response = MockResponse(200, "Definitely an ICS") + mock_request.return_value = external_response + + with open( + accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r" + ) as f: + assert f.read() == external_response.value + + mock_request.return_value = MockResponse(200, "This should be ignored") + with open( + accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r" + ) as f: + assert f.read() == external_response.value + + mock_request.assert_called_once() + + fake_current_time.return_value = start_time + timedelta(hours=1, seconds=1) + external_response = MockResponse(200, "This won't be ignored") + mock_request.return_value = external_response + + with open( + accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r" + ) as f: + assert f.read() == external_response.value + + assert mock_request.call_count == 2 From 2749a88704b11d48c4e33d19f5e198b8aadcec8b Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 5 Jan 2025 01:36:41 +0100 Subject: [PATCH 20/20] Basic test for internal calendar --- com/tests/test_api.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/com/tests/test_api.py b/com/tests/test_api.py index cfc0e35f..f131052e 100644 --- a/com/tests/test_api.py +++ b/com/tests/test_api.py @@ -106,3 +106,17 @@ class TestExternalCalendar: assert f.read() == external_response.value assert mock_request.call_count == 2 + + +@pytest.mark.django_db +class TestInternalCalendar: + @pytest.fixture(autouse=True) + def clear_cache(self): + IcsCalendar._INTERNAL_CALENDAR.unlink(missing_ok=True) + + def test_fetch_success(self, client: Client): + response = client.get(reverse("api:calendar_internal")) + assert response.status_code == 200 + out_file = accel_redirect_to_file(response) + assert out_file is not None + assert out_file.exists()