11 Commits

Author SHA1 Message Date
dae5cb06e7 fix rebase 2024-11-27 15:40:25 +01:00
113828f9b6 refactor and corrections for PR 2024-11-27 15:00:10 +01:00
203b5d88ac Fix missing current permanency display, delegate slot label config to locales 2024-11-27 15:00:07 +01:00
9206fed4ce Implemented locales + previous weeks in time graph 2024-11-27 15:00:05 +01:00
f133bac921 CSS Fix 2024-11-27 15:00:05 +01:00
1bce7e055f Round all perms to the quarter 2024-11-27 15:00:05 +01:00
ee19dc01f6 Global code cleanup 2024-11-27 15:00:02 +01:00
09dbda87bc Activity TimeGrid WIP 2024-11-27 14:58:30 +01:00
a44e8a68cb export graph to html function 2024-11-27 14:58:27 +01:00
71d155613f functionnal api 2024-11-27 14:58:24 +01:00
e30a6e8e6e api attempt 2024-11-27 14:57:33 +01:00
10 changed files with 578 additions and 292 deletions

View File

@ -19,13 +19,16 @@ from django.db.models import Q
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
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 (
CounterFilterSchema,
CounterSchema,
PermanencyFilterSchema,
PermanencySchema,
ProductSchema,
SimplifiedCounterSchema,
)
@ -35,17 +38,15 @@ from counter.schemas import (
class CounterController(ControllerBase):
@route.get("", response=list[CounterSchema], permissions=[IsRoot])
def fetch_all(self):
return Counter.objects.annotate_is_open()
return Counter.objects.all()
@route.get("{counter_id}/", response=CounterSchema, permissions=[CanView])
def fetch_one(self, counter_id: int):
return self.get_object_or_exception(
Counter.objects.annotate_is_open(), pk=counter_id
)
return self.get_object_or_exception(Counter.objects.all(), pk=counter_id)
@route.get("bar/", response=list[CounterSchema], permissions=[CanView])
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:
self.check_object_permissions(c)
return counters
@ -76,3 +77,21 @@ class ProductController(ControllerBase):
.filter(archived=False)
.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")
)

View File

@ -1,19 +1,33 @@
from datetime import datetime
from typing import Annotated
from annotated_types import MinLen
from ninja import Field, FilterSchema, ModelSchema
from core.schemas import SimpleUserSchema
from counter.models import Counter, Product
from counter.models import Counter, Permanency, Product
class CounterSchema(ModelSchema):
barmen_list: list[SimpleUserSchema]
is_open: bool
class Meta:
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):

View File

@ -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;
}

View File

@ -21,4 +21,21 @@
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;
}
}

View File

@ -5,6 +5,10 @@
{% trans counter_name=counter %}{{ counter_name }} activity{% endtrans %}
{% endblock %}
{% block additional_js %}
<script defer src="{{ static('bundled/counter/permanencies/time-grid-index.ts') }}" type="module"></script>
{% endblock %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('counter/css/activity.scss') }}">
{%- endblock -%}
@ -22,6 +26,10 @@
{% trans %}There is currently no barman connected.{% endtrans %}
{% endif %}
</ul>
<h4>{% trans %}Last weeks opening times{% endtrans %}</h4>
<div id="activityTimeGrid"></div>
<br/>
<br/>
{% endif %}
<h5>{% trans %}Legend{% endtrans %}</h5>
@ -37,5 +45,17 @@
</div>
{% 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 %}

View File

@ -6143,3 +6143,7 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format
msgid "Maximum characters: %(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"

613
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,8 @@
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@hey-api/client-fetch": "^0.4.0",
"@sentry/browser": "^8.34.0",
"@zip.js/zip.js": "^2.7.52",

View File

@ -164,6 +164,7 @@ TEMPLATES = [
"ProductType": "counter.models.ProductType",
"timezone": "django.utils.timezone",
"get_sith": "com.views.sith",
"get_current_language": "django.views.i18n.get_language",
},
"bytecode_cache": {
"name": "default",

View File