10 changed files with 578 additions and 292 deletions
+25 -6
View File
@@ -19,13 +19,16 @@ from django.db.models import Q
from ninja import Query from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from core.api_permissions import CanAccessLookup, CanView, IsRoot from core.api_permissions import CanAccessLookup, CanView, IsRoot
from counter.models import Counter, Product from counter.models import Counter, Permanency, Product
from counter.schemas import ( from counter.schemas import (
CounterFilterSchema, CounterFilterSchema,
CounterSchema, CounterSchema,
PermanencyFilterSchema,
PermanencySchema,
ProductSchema, ProductSchema,
SimplifiedCounterSchema, SimplifiedCounterSchema,
) )
@@ -35,17 +38,15 @@ from counter.schemas import (
class CounterController(ControllerBase): class CounterController(ControllerBase):
@route.get("", response=list[CounterSchema], permissions=[IsRoot]) @route.get("", response=list[CounterSchema], permissions=[IsRoot])
def fetch_all(self): def fetch_all(self):
return Counter.objects.annotate_is_open() return Counter.objects.all()
@route.get("{counter_id}/", response=CounterSchema, permissions=[CanView]) @route.get("{counter_id}/", response=CounterSchema, permissions=[CanView])
def fetch_one(self, counter_id: int): def fetch_one(self, counter_id: int):
return self.get_object_or_exception( return self.get_object_or_exception(Counter.objects.all(), pk=counter_id)
Counter.objects.annotate_is_open(), pk=counter_id
)
@route.get("bar/", response=list[CounterSchema], permissions=[CanView]) @route.get("bar/", response=list[CounterSchema], permissions=[CanView])
def fetch_bars(self): def fetch_bars(self):
counters = list(Counter.objects.annotate_is_open().filter(type="BAR")) counters = list(Counter.objects.all().filter(type="BAR"))
for c in counters: for c in counters:
self.check_object_permissions(c) self.check_object_permissions(c)
return counters return counters
@@ -76,3 +77,21 @@ class ProductController(ControllerBase):
.filter(archived=False) .filter(archived=False)
.values() .values()
) )
@api_controller("/permanency")
class PermanencyController(ControllerBase):
@route.get(
"",
response=PaginatedResponseSchema[PermanencySchema],
permissions=[IsAuthenticated],
exclude_none=True,
)
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_permanencies(self, filters: Query[PermanencyFilterSchema]):
return (
filters.filter(Permanency.objects.all())
.distinct()
.order_by("-start")
.select_related("counter")
)
+20 -6
View File
@@ -1,19 +1,33 @@
from datetime import datetime
from typing import Annotated from typing import Annotated
from annotated_types import MinLen from annotated_types import MinLen
from ninja import Field, FilterSchema, ModelSchema from ninja import Field, FilterSchema, ModelSchema
from core.schemas import SimpleUserSchema from counter.models import Counter, Permanency, Product
from counter.models import Counter, Product
class CounterSchema(ModelSchema): class CounterSchema(ModelSchema):
barmen_list: list[SimpleUserSchema]
is_open: bool
class Meta: class Meta:
model = Counter model = Counter
fields = ["id", "name", "type", "club", "products"] fields = ["id", "name", "type"]
class PermanencySchema(ModelSchema):
counter: CounterSchema
class Meta:
model = Permanency
fields = ["start", "end"]
class PermanencyFilterSchema(FilterSchema):
start_after: datetime | None = Field(None, q="start__gte")
start_before: datetime | None = Field(None, q="start__lte")
end_after: datetime | None = Field(None, q="end__gte")
end_before: datetime | None = Field(None, q="end__lte")
took_place_after: datetime | None = Field(None, q=["start__gte", "end__gte"])
counter: set[int] | None = Field(None, q="counter_id__in")
class CounterFilterSchema(FilterSchema): class CounterFilterSchema(FilterSchema):
@@ -0,0 +1,152 @@
import { paginated } from "#core:utils/api";
import { exportToHtml } from "#core:utils/globals";
import { Calendar } from "@fullcalendar/core";
import timeGridPlugin from "@fullcalendar/timegrid";
import {
type PermanencyFetchPermanenciesData,
type PermanencySchema,
permanencyFetchPermanencies,
} from "#openapi";
interface ActivityTimeGridConfig {
canvas: HTMLCanvasElement;
startDate: Date;
counterId: number;
locale: string;
}
interface OpeningTime {
start: Date;
end: Date;
}
interface EventInput {
start: Date;
end: Date;
backgroundColor: string;
title?: string;
}
exportToHtml("loadActivityTimeGrid", loadActivityTimeGrid);
async function loadActivityTimeGrid(options: ActivityTimeGridConfig) {
const permanencies = await paginated(permanencyFetchPermanencies, {
query: {
counter: [options.counterId],
// biome-ignore lint/style/useNamingConvention: backend API uses snake_case
took_place_after: options.startDate.toISOString(),
},
} as PermanencyFetchPermanenciesData);
const events = getEvents(permanencies);
const calendar = new Calendar(options.canvas, {
plugins: [timeGridPlugin],
initialView: "timeGridWeek",
locale: options.locale,
dayHeaderFormat: { weekday: "long" },
firstDay: 1,
views: { timeGrid: { allDaySlot: false } },
scrollTime: "09:00:00",
headerToolbar: { left: "prev today", center: "title", right: "" },
events: events,
nowIndicator: true,
height: 600,
});
calendar.render();
calendar.on("datesSet", async (info) => {
if (options.startDate <= info.start) {
return;
}
const newPerms = await paginated(permanencyFetchPermanencies, {
query: {
counter: [options.counterId],
// biome-ignore lint/style/useNamingConvention: backend API uses snake_case
end_after: info.startStr,
// biome-ignore lint/style/useNamingConvention: backend API uses snake_case
start_before: info.endStr,
},
} as PermanencyFetchPermanenciesData);
options.startDate = info.start;
calendar.addEventSource(getEvents(newPerms, false));
permanencies.push(...newPerms);
calendar.render();
});
}
function roundToQuarter(date: Date, ceil: boolean) {
const result = date;
const minutes = date.getMinutes();
// removes minutes exceeding the lower quarter and adds 15 minutes if rounded to ceiling
result.setMinutes(minutes - (minutes % 15) + +ceil * 15, 0, 0);
return result;
}
function convertPermanencyToOpeningTime(permanency: PermanencySchema): OpeningTime {
return {
start: roundToQuarter(new Date(permanency.start), false),
end: roundToQuarter(new Date(permanency.end ?? Date.now()), true),
};
}
function getOpeningTimes(rawPermanencies: PermanencySchema[]) {
const permanencies = rawPermanencies
.map(convertPermanencyToOpeningTime)
.sort((a, b) => a.start.getTime() - b.start.getTime());
const openingTimes: OpeningTime[] = [];
for (const permanency of permanencies) {
// if there are no opening times, add the first one
if (openingTimes.length === 0) {
openingTimes.push(permanency);
} else {
const lastPermanency = openingTimes[openingTimes.length - 1];
// if the new permanency starts before the 15 minutes following the end of the last one, merge them
if (permanency.start <= lastPermanency.end) {
lastPermanency.end = new Date(
Math.max(lastPermanency.end.getTime(), permanency.end.getTime()),
);
} else {
openingTimes.push(permanency);
}
}
}
return openingTimes;
}
function getEvents(permanencies: PermanencySchema[], currentWeek = true): EventInput[] {
const openingTimes = getOpeningTimes(permanencies);
const events: EventInput[] = [];
for (const openingTime of openingTimes) {
let shift = false;
if (currentWeek) {
const lastMonday = getLastMonday();
shift = openingTime.end < lastMonday;
}
// if permanencies took place last week (=before monday),
// -> display them in lightblue as part of the current week
events.push({
start: shift ? shiftDateByDays(openingTime.start, 7) : openingTime.start,
end: shift ? shiftDateByDays(openingTime.end, 7) : openingTime.end,
backgroundColor: shift ? "lightblue" : "green",
});
}
return events;
}
// Function to get last Monday at 00:00
function getLastMonday(now = new Date()): Date {
const dayOfWeek = now.getDay();
const lastMonday = new Date(now);
lastMonday.setDate(now.getDate() - ((dayOfWeek + 6) % 7)); // Adjust for Monday as day 1
lastMonday.setHours(0, 0, 0, 0);
return lastMonday;
}
function shiftDateByDays(date: Date, days: number): Date {
const newDate = new Date(date);
newDate.setDate(date.getDate() + days);
return newDate;
}
+17
View File
@@ -21,4 +21,21 @@
align-items: center; align-items: center;
} }
} }
}
#activityTimeGrid {
th {
border: none;
}
.fc-col-header-cell-cushion {
color: white;
}
table table {
// the JS library puts tables inside the table
// Those additional tables must be hidden
border: none;
box-shadow: none;
}
} }
+22 -2
View File
@@ -5,6 +5,10 @@
{% trans counter_name=counter %}{{ counter_name }} activity{% endtrans %} {% trans counter_name=counter %}{{ counter_name }} activity{% endtrans %}
{% endblock %} {% endblock %}
{% block additional_js %}
<script defer src="{{ static('bundled/counter/permanencies/time-grid-index.ts') }}" type="module"></script>
{% endblock %}
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('counter/css/activity.scss') }}"> <link rel="stylesheet" href="{{ static('counter/css/activity.scss') }}">
{%- endblock -%} {%- endblock -%}
@@ -22,6 +26,10 @@
{% trans %}There is currently no barman connected.{% endtrans %} {% trans %}There is currently no barman connected.{% endtrans %}
{% endif %} {% endif %}
</ul> </ul>
<h4>{% trans %}Last weeks opening times{% endtrans %}</h4>
<div id="activityTimeGrid"></div>
<br/>
<br/>
{% endif %} {% endif %}
<h5>{% trans %}Legend{% endtrans %}</h5> <h5>{% trans %}Legend{% endtrans %}</h5>
@@ -37,5 +45,17 @@
</div> </div>
{% endblock %} {% endblock %}
{% block script %}
{{super()}}
<script>
window.addEventListener("DOMContentLoaded", () => {
loadActivityTimeGrid({
canvas: document.getElementById("activityTimeGrid"),
// sets the start day to 7 days ago
startDate: new Date(new Date().setDate(new Date().getDate() - 7)),
counterId: {{ counter.id }},
locale: {{ get_current_language()|tojson }}
});
});
</script>
{% endblock %}
+4
View File
@@ -6143,3 +6143,7 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format #, python-format
msgid "Maximum characters: %(max_length)s" msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s"
#: counter/activity.jinja
msgid "Last weeks opening times"
msgstr "Heures d'ouverture des dernières semaines"
+335 -278
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -34,6 +34,8 @@
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@hey-api/client-fetch": "^0.4.0", "@hey-api/client-fetch": "^0.4.0",
"@sentry/browser": "^8.34.0", "@sentry/browser": "^8.34.0",
"@zip.js/zip.js": "^2.7.52", "@zip.js/zip.js": "^2.7.52",
+1
View File
@@ -164,6 +164,7 @@ TEMPLATES = [
"ProductType": "counter.models.ProductType", "ProductType": "counter.models.ProductType",
"timezone": "django.utils.timezone", "timezone": "django.utils.timezone",
"get_sith": "com.views.sith", "get_sith": "com.views.sith",
"get_current_language": "django.views.i18n.get_language",
}, },
"bytecode_cache": { "bytecode_cache": {
"name": "default", "name": "default",
View File