Compare commits

..

27 Commits

Author SHA1 Message Date
djalim 080f7606a7 refactor(api_mock.ts): remove async from mockFetch to match signature
Check Deno code / Check Deno code (pull_request) Successful in 7s
Build and push image / Build Docker image (push) Successful in 2m11s
2026-04-21 12:04:10 +02:00
djalim 4e220f72d7 style: format api mock return type and test imports/JSON body 2026-04-21 12:02:55 +02:00
djalim 61207e4f21 test: add mock DB helper for unit tests
Check Deno code / Check Deno code (pull_request) Failing after 39s
test: add tests for fixtures, mock fetch, mock db, and happy-dom

- Add comprehensive fixture shape tests.
- Expand mockFetch to support methods, status codes, and body tracking.
- Introduce getFetchCalls to inspect intercepted requests.
- Add mockDb helper for in-memory DB operations.
- Reorganize tests for clarity and coverage.
- Ensure happy-dom setup/cleanup works correctly.
2026-04-21 11:49:30 +02:00
djalim 204a590b37 refactor(test): improve fetch mock and update fixture types
Add support for HTTP methods, status codes, body and headers in the fetch
mock. Track calls and expose getFetchCalls for assertions. Update fixture
interfaces to use string IDs, add ImportResult and ApiError types, and
provide standard error constants. Adjust fixture data to match new types.
2026-04-21 11:31:45 +02:00
djalim edb20db2ef test: add e2e, integration, and unit tests for fixtures and mockFetch 2026-04-21 11:24:02 +02:00
djalim 56430f9991 test: add API mock, fixtures, and DOM helpers for tests 2026-04-21 11:23:21 +02:00
djalim 808bf8c9c7 ci: add test job to lint workflow and update deno.json
Add test script to deno.json
Add @std/assert, @std/testing, happy-dom dependencies
2026-04-21 11:22:09 +02:00
djalim e111d5be28 Merge pull request '🐛 (Dockerfile): remove stray 'flag' argument from deno cache command' (#6) from feature/deploy into main
Build and push image / Build Docker image (push) Successful in 20s
Reviewed-on: https://git.polytech.djalim.fr/admin/PolyMPR/pulls/6
2026-01-13 08:08:24 +00:00
djalim c70d4a5f11 🐛 (Dockerfile): remove stray 'flag' argument from deno cache command
Check Deno code / Check Deno code (pull_request) Successful in 14s
2026-01-13 09:07:01 +01:00
djalim ad5c271b05 Merge pull request 'feature/deploy : Added deploy ci tu automaticaly push image to registry' (#5) from feature/deploy into main
Build and push image / Build Docker image (push) Failing after 1m35s
Reviewed-on: https://git.polytech.djalim.fr/admin/PolyMPR/pulls/5
2026-01-13 07:47:18 +00:00
djalim 19a588ac25 🚀 ci: add Docker build and push workflow, remove old Deno check workflow
Check Deno code / Check Deno code (pull_request) Successful in 16s
2026-01-13 08:45:23 +01:00
djalim ad524978df 🔧 (ci.yml): renamed Deno lint workflow for pull requests 2026-01-09 12:57:51 +01:00
djalim 2ce78547b7 Merge pull request '🔧 chore(ci): removed old github workflow folder' (#4) from fix/action into main
Reviewed-on: https://git.polytech.djalim.fr/admin/PolyMPR/pulls/4
2026-01-09 11:52:25 +00:00
djalim 81a3fc0e03 🔧 chore(ci): removed old github workflow folder
Check Deno code / Check Deno code (pull_request) Successful in 13s
2026-01-09 12:50:46 +01:00
djalim 2615961bf1 Merge pull request '🔧 ci: add Deno lint and format check workflow' (#1) from feature/actions into main
Reviewed-on: https://git.polytech.djalim.fr/admin/PolyMPR/pulls/1
2026-01-09 11:48:18 +00:00
djalim fb967e3af3 Merge pull request '🔧 chore: add env.template and load .env instead of .env.development.local' (#2) from feature/dotenv into main
Reviewed-on: https://git.polytech.djalim.fr/admin/PolyMPR/pulls/2
2026-01-09 11:48:05 +00:00
djalim c283a34784 🔧 ci: add Deno lint and format check workflow
Check Deno code / Check Deno code (pull_request) Successful in 5s
2026-01-09 12:47:36 +01:00
djalim a48c616ecc 🔧 chore: add env.template and load .env instead of .env.development.local
Check Deno code / Check Deno code (pull_request) Successful in 5s
2026-01-09 12:47:11 +01:00
djalim cd1149a23a Merge pull request '🐛(components): add missing button types and keys' (#3) from fix/actions into main
Reviewed-on: https://git.polytech.djalim.fr/admin/PolyMPR/pulls/3
2026-01-09 11:44:17 +00:00
djalim 58c8ff56ba 🐛(components): add missing button types and keys
Check Deno code / Check Deno code (pull_request) Successful in 40s
Add type="button" to the EditMobility and UploadStudents buttons
to prevent default form submission behavior.
Include a key prop on Student components in Promotion for stable list rendering.
2026-01-09 12:41:51 +01:00
Kevin FEDYNA 91f7b6c022 Merge pull request #39 from fedyna-k/PMPR-38
Added all files
2025-01-28 23:44:06 +01:00
Kevin FEDYNA 8f1088a0f8 Deno police 2025-01-28 23:43:36 +01:00
Kevin FEDYNA ccbec884e8 Added all files 2025-01-28 23:41:10 +01:00
Kevin FEDYNA a9a2f6f390 Merge pull request #36 from fedyna-k/PMPR-35
Pmpr 35
2025-01-28 18:38:17 +01:00
Kevin FEDYNA 4c54283bfd Finalized students app 2025-01-28 10:03:20 +01:00
Kevin FEDYNA e88045c952 Refactored students 2025-01-27 13:11:13 +01:00
Kevin FEDYNA 4ff76fdf6f Added hidden admin only page prop effect 2025-01-27 10:39:42 +01:00
49 changed files with 1830 additions and 775 deletions
+27
View File
@@ -0,0 +1,27 @@
name: "Build and push image"
on:
push:
branches:
- main
jobs:
deploy:
name: "Build Docker image"
runs-on: ubuntu-latest
steps:
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: registry.docker.polytech.djalim.fr
username: ${{ secrets.registry_login }}
password: ${{ secrets.registry_pass }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: registry.docker.polytech.djalim.fr/polympr:latest
@@ -24,3 +24,6 @@ jobs:
- name: Check linting - name: Check linting
run: deno lint run: deno lint
- name: Run tests
run: deno test -A --no-check tests/
+2 -2
View File
@@ -3,11 +3,11 @@ FROM denoland/deno:alpine
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN deno cache main.ts --allow-import flag RUN deno cache main.ts --allow-import
RUN deno task build RUN deno task build
USER deno USER deno
EXPOSE 80 EXPOSE 80
EXPOSE 443 EXPOSE 443
CMD ["run", "-A", "main.ts"] CMD ["run", "-A", "main.ts"]
+20
View File
@@ -0,0 +1,20 @@
# Contributing
Thank you for your interest in contributing to our project! We appreciate your
help in making this project better. To get started with contributing, please
refer to our
[Contributing Guide](https://github.com/fedyna-k/PolyMPR/wiki/Contributing) on
the project's wiki.
The Contributing Guide provides detailed information on how to:
- Set up your development environment
- Submit issues and feature requests
- Fork the repository and create pull requests
- Follow our coding standards and guidelines
- Report bugs and suggest improvements
If you have any questions or need further assistance, feel free to reach out to
us by opening an issue or contacting the maintainers directly.
Happy coding! 💻✨
+1 -1
View File
@@ -7,5 +7,5 @@ CREATE TABLE mobility (
destinationCountry text, destinationCountry text,
destinationName text, destinationName text,
mobilityStatus text default 'N/A', mobilityStatus text default 'N/A',
attestationFile blob foreign key (studentId) references students(userId)
); );
+8 -9
View File
@@ -1,15 +1,14 @@
create table promotions ( create table promotions (
id integer primary key autoincrement, id integer primary key autoincrement,
name text, endyear integer,
endyear integer, current integer
current integer
); );
create table students ( create table students (
userId text primary key, userId text primary key,
firstName text, firstName text,
lastName text, lastName text,
mail text, mail text,
promotionId integer, promotionId integer,
foreign key(promotionId) references promotions(id) foreign key(promotionId) references promotions(id)
); );
+2 -1
View File
@@ -1,9 +1,10 @@
import { type RegularTagNode, type TextNode } from "@melvdouc/xml-parser"; import { type RegularTagNode, type TextNode } from "@melvdouc/xml-parser";
import { AsyncRoute } from "$fresh/src/server/types.ts"; import { AsyncRoute } from "$fresh/src/server/types.ts";
interface AuthenticatedState { export interface AuthenticatedState {
isAuthenticated: true; isAuthenticated: true;
session: CasContent; session: CasContent;
availablePages: Record<string, string>;
} }
interface UnauthenticatedState { interface UnauthenticatedState {
+8 -2
View File
@@ -9,7 +9,8 @@
"start": "deno run -A --unstable-ffi --watch=static/,routes/ dev.ts", "start": "deno run -A --unstable-ffi --watch=static/,routes/ dev.ts",
"build": "deno run -A --unstable-ffi dev.ts build", "build": "deno run -A --unstable-ffi dev.ts build",
"preview": "deno run -A --unstable-ffi main.ts", "preview": "deno run -A --unstable-ffi main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ." "update": "deno run -A -r https://fresh.deno.dev/update .",
"test": "deno test -A --no-check tests/"
}, },
"lint": { "lint": {
"rules": { "rules": {
@@ -29,12 +30,17 @@
"@popov/jwt": "jsr:@popov/jwt@^1.0.1", "@popov/jwt": "jsr:@popov/jwt@^1.0.1",
"@psych/sheet": "jsr:@psych/sheet@^1.0.6", "@psych/sheet": "jsr:@psych/sheet@^1.0.6",
"@std/cli": "jsr:@std/cli@^1.0.10", "@std/cli": "jsr:@std/cli@^1.0.10",
"@std/dotenv": "jsr:@std/dotenv@^0.225.3",
"preact": "https://esm.sh/preact@10.22.0", "preact": "https://esm.sh/preact@10.22.0",
"preact/": "https://esm.sh/preact@10.22.0/", "preact/": "https://esm.sh/preact@10.22.0/",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
"$std/": "https://deno.land/std@0.216.0/", "$std/": "https://deno.land/std@0.216.0/",
"$root/": "./" "@std/assert": "jsr:@std/assert@^1.0.0",
"@std/testing": "jsr:@std/testing@^1.0.0",
"happy-dom": "npm:happy-dom@^16.0.0",
"$root/": "./",
"$apps/": "./routes/(apps)/"
}, },
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",
+2
View File
@@ -0,0 +1,2 @@
#Local mode, set to true to access admin pages with any users
LOCAL=false
+2
View File
@@ -1,6 +1,8 @@
import { defineConfig } from "$fresh/server.ts"; import { defineConfig } from "$fresh/server.ts";
import ensureDatabases from "$root/databases/ensure.ts"; import ensureDatabases from "$root/databases/ensure.ts";
import { load } from "@std/dotenv";
await load({ envPath: "./.env", export: true });
await ensureDatabases(); await ensureDatabases();
export default defineConfig({ export default defineConfig({
server: { server: {
+11 -12
View File
@@ -3,23 +3,22 @@
// This file is automatically updated during development when running `dev.ts`. // This file is automatically updated during development when running `dev.ts`.
import * as $_apps_layout from "./routes/(apps)/_layout.tsx"; import * as $_apps_layout from "./routes/(apps)/_layout.tsx";
import * as $_apps_mobility_api_download from "./routes/(apps)/mobility/api/download.ts"; import * as $_apps_middleware from "./routes/(apps)/_middleware.ts";
import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert-mobility.ts"; import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts";
import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.tsx"; import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.tsx";
import * as $_apps_mobility_partials_admin_edit_mobility from "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx"; import * as $_apps_mobility_partials_admin_edit_mobility from "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx";
import * as $_apps_mobility_partials_index from "./routes/(apps)/mobility/partials/index.tsx"; import * as $_apps_mobility_partials_index from "./routes/(apps)/mobility/partials/index.tsx";
import * as $_apps_mobility_partials_overview from "./routes/(apps)/mobility/partials/overview.tsx"; import * as $_apps_mobility_partials_overview from "./routes/(apps)/mobility/partials/overview.tsx";
import * as $_apps_mobility_types_d from "./routes/(apps)/mobility/types.d.ts";
import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx"; import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx";
import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx"; import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx";
import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx";
import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx";
import * as $_apps_students_api_insert_students from "./routes/(apps)/students/api/insert_students.ts"; import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts";
import * as $_apps_students_index from "./routes/(apps)/students/index.tsx"; import * as $_apps_students_index from "./routes/(apps)/students/index.tsx";
import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx"; import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx";
import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx"; import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx";
import * as $_apps_students_partials_index from "./routes/(apps)/students/partials/index.tsx"; import * as $_apps_students_partials_index from "./routes/(apps)/students/partials/index.tsx";
import * as $_apps_students_partials_overview from "./routes/(apps)/students/partials/overview.tsx"; import * as $_apps_students_types_d from "./routes/(apps)/students/types.d.ts";
import * as $_404 from "./routes/_404.tsx"; import * as $_404 from "./routes/_404.tsx";
import * as $_app from "./routes/_app.tsx"; import * as $_app from "./routes/_app.tsx";
import * as $_middleware from "./routes/_middleware.ts"; import * as $_middleware from "./routes/_middleware.ts";
@@ -32,6 +31,7 @@ import * as $_islands_AppNavigator from "./routes/(_islands)/AppNavigator.tsx";
import * as $_islands_Navbar from "./routes/(_islands)/Navbar.tsx"; import * as $_islands_Navbar from "./routes/(_islands)/Navbar.tsx";
import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx"; import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx";
import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/(_islands)/EditMobility.tsx"; import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/(_islands)/EditMobility.tsx";
import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.tsx";
import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx"; import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx";
import * as $_apps_students_islands_EditStudents from "./routes/(apps)/students/(_islands)/EditStudents.tsx"; import * as $_apps_students_islands_EditStudents from "./routes/(apps)/students/(_islands)/EditStudents.tsx";
import * as $_apps_students_islands_UploadStudents from "./routes/(apps)/students/(_islands)/UploadStudents.tsx"; import * as $_apps_students_islands_UploadStudents from "./routes/(apps)/students/(_islands)/UploadStudents.tsx";
@@ -40,8 +40,8 @@ import type { Manifest } from "$fresh/server.ts";
const manifest = { const manifest = {
routes: { routes: {
"./routes/(apps)/_layout.tsx": $_apps_layout, "./routes/(apps)/_layout.tsx": $_apps_layout,
"./routes/(apps)/mobility/api/download.ts": $_apps_mobility_api_download, "./routes/(apps)/_middleware.ts": $_apps_middleware,
"./routes/(apps)/mobility/api/insert-mobility.ts": "./routes/(apps)/mobility/api/insert_mobility.ts":
$_apps_mobility_api_insert_mobility, $_apps_mobility_api_insert_mobility,
"./routes/(apps)/mobility/index.tsx": $_apps_mobility_index, "./routes/(apps)/mobility/index.tsx": $_apps_mobility_index,
"./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx": "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx":
@@ -50,14 +50,12 @@ const manifest = {
$_apps_mobility_partials_index, $_apps_mobility_partials_index,
"./routes/(apps)/mobility/partials/overview.tsx": "./routes/(apps)/mobility/partials/overview.tsx":
$_apps_mobility_partials_overview, $_apps_mobility_partials_overview,
"./routes/(apps)/mobility/types.d.ts": $_apps_mobility_types_d,
"./routes/(apps)/notes/index.tsx": $_apps_notes_index, "./routes/(apps)/notes/index.tsx": $_apps_notes_index,
"./routes/(apps)/notes/partials/(admin)/courses.tsx": "./routes/(apps)/notes/partials/(admin)/courses.tsx":
$_apps_notes_partials_admin_courses, $_apps_notes_partials_admin_courses,
"./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index,
"./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, "./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes,
"./routes/(apps)/students/api/insert_students.ts": "./routes/(apps)/students/api/students.ts": $_apps_students_api_students,
$_apps_students_api_insert_students,
"./routes/(apps)/students/index.tsx": $_apps_students_index, "./routes/(apps)/students/index.tsx": $_apps_students_index,
"./routes/(apps)/students/partials/(admin)/consult.tsx": "./routes/(apps)/students/partials/(admin)/consult.tsx":
$_apps_students_partials_admin_consult, $_apps_students_partials_admin_consult,
@@ -65,8 +63,7 @@ const manifest = {
$_apps_students_partials_admin_upload, $_apps_students_partials_admin_upload,
"./routes/(apps)/students/partials/index.tsx": "./routes/(apps)/students/partials/index.tsx":
$_apps_students_partials_index, $_apps_students_partials_index,
"./routes/(apps)/students/partials/overview.tsx": "./routes/(apps)/students/types.d.ts": $_apps_students_types_d,
$_apps_students_partials_overview,
"./routes/_404.tsx": $_404, "./routes/_404.tsx": $_404,
"./routes/_app.tsx": $_app, "./routes/_app.tsx": $_app,
"./routes/_middleware.ts": $_middleware, "./routes/_middleware.ts": $_middleware,
@@ -83,6 +80,8 @@ const manifest = {
$_apps_mobility_islands_ConsultMobility, $_apps_mobility_islands_ConsultMobility,
"./routes/(apps)/mobility/(_islands)/EditMobility.tsx": "./routes/(apps)/mobility/(_islands)/EditMobility.tsx":
$_apps_mobility_islands_EditMobility, $_apps_mobility_islands_EditMobility,
"./routes/(apps)/mobility/(_islands)/ImportFile.tsx":
$_apps_mobility_islands_ImportFile,
"./routes/(apps)/students/(_islands)/ConsultStudents.tsx": "./routes/(apps)/students/(_islands)/ConsultStudents.tsx":
$_apps_students_islands_ConsultStudents, $_apps_students_islands_ConsultStudents,
"./routes/(apps)/students/(_islands)/EditStudents.tsx": "./routes/(apps)/students/(_islands)/EditStudents.tsx":
+18
View File
@@ -0,0 +1,18 @@
Copyright 2025 - PolyMPR team @ Polytech Marseille
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+87 -2
View File
@@ -1,4 +1,89 @@
# ✨ PolyMPR ✨ # ✨ PolyMPR ✨
The ✨ Poly Module de Pilotage des Ressources is the ultimate tool to handle **PolyMPR** (Poly Management Platform for Resources) is a modern, modular
various HR task in the INFO department. framework built on **Deno** and **Fresh**, designed to help organizations
transition their HR systems to the cloud. With its **modulith architecture**,
PolyMPR simplifies the development, deployment, and maintenance of HR
applications, making it the perfect choice for teams looking to modernize their
workflows. 🌐
## Features ✨
- **Modular Design**: Easily add, remove, or update features without disrupting
the entire system. 🧩
- **Cloud-Native**: Built for the cloud, enabling seamless integration with
cloud services (amU DataCenter). ☁️
- **Deno-Powered**: Utilizes Deno's secure runtime for TypeScript. 🦕
- **Fresh Framework**: Delivers fast, edge-ready web applications with minimal
overhead. ⚡
- **HR-Focused**: Tailored to meet the unique needs of INFO's HR. 👩‍💼👨‍💼
## Getting Started 🛠️
### Prerequisites
- **Deno**: Install Deno by following the
[official guide](https://deno.land/#installation).
- **Docker** (optional): Install Docker for containerized deployments. Follow
the [Docker installation guide](https://docs.docker.com/get-docker/).
### Installation
1. Clone the PolyMPR repository:
```bash
git clone https://github.com/fedyna-k/PolyMPR.git
cd PolyMPR
```
2. Start the application:
```bash
deno task start
```
3. Access the application at `https://localhost`.
For detailed installation instructions, check out the
[Installation Guide](./wiki/installation).
## Modules Overview 🧩
PolyMPR comes with a variety of modules to streamline HR processes.
To learn how to create a module, visit the [Module Overview](./wiki/modules).
## CLI Documentation 📄
The **PolyMPR CLI** simplifies development tasks. Here are some common commands:
- Create a new module:
```bash
pmpr module create <module-name-kebab-case>
```
For detailed CLI usage, check out the [CLI Documentation](./wiki/cli).
## Contributing 🤝
We welcome contributions from the community! Whether you're fixing bugs, adding
features, or improving documentation, your help is appreciated. Heres how to
get started:
1. Create a new issue.
2. Create a new branch for your changes:
```bash
git checkout -b PMPR-:ISSUE_ID:
```
3. Commit your changes and push them to your branch.
4. Submit a pull request.
For more details, read the [Contributing Guide](./contributing).
## Community and Support 🌟
Join the PolyMPR community to connect with other users and developers:
- **GitHub Discussions**: Ask questions and share ideas. 💬
- **Issue Tracker**: Report bugs or request features. 🐛
## License 📜
PolyMPR is open-source and released under the **MIT License**. Feel free to use,
modify, and distribute it as per the license terms.
+4 -7
View File
@@ -1,22 +1,19 @@
import { FreshContext } from "$fresh/server.ts"; import { FreshContext } from "$fresh/server.ts";
import { Partial } from "$fresh/runtime.ts"; import { Partial } from "$fresh/runtime.ts";
import { State } from "$root/defaults/interfaces.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { AppProperties } from "$root/defaults/interfaces.ts";
import Navbar from "$root/routes/(_islands)/Navbar.tsx"; import Navbar from "$root/routes/(_islands)/Navbar.tsx";
// deno-lint-ignore require-await
export default async function AppLayout( export default async function AppLayout(
request: Request, request: Request,
context: FreshContext<State>, context: FreshContext<AuthenticatedState>,
) { ) {
const pathname = new URL(request.url).pathname; const pathname = new URL(request.url).pathname;
const currentApp = pathname.split("/")[1]; const currentApp = pathname.split("/")[1];
const properties: AppProperties = (await import(
`./${currentApp}/(_props)/props.ts`
)).default;
return ( return (
<section id="app"> <section id="app">
<Navbar currentApp={currentApp} pages={properties.pages} /> <Navbar currentApp={currentApp} pages={context.state.availablePages} />
<section id="app-body"> <section id="app-body">
<Partial name="body"> <Partial name="body">
<context.Component /> <context.Component />
+36
View File
@@ -0,0 +1,36 @@
import { FreshContext, MiddlewareHandler } from "$fresh/server.ts";
import {
AppProperties,
AuthenticatedState,
} from "$root/defaults/interfaces.ts";
export const handler: MiddlewareHandler<AuthenticatedState>[] = [
/**
* Get all available pages for current user.
* @param request The HTTP incomming request.
* @param context The Fresh context object with custom `AuthenticatedState`.
* @returns The response from the next middleware.
*/
async function getAllAvailablePages(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const pathname = new URL(request.url).pathname;
const currentApp = pathname.split("/")[1];
const properties: AppProperties = (await import(
`./${currentApp}/(_props)/props.ts`
)).default;
context.state.availablePages = properties.pages;
if (
context.state.session.eduPersonPrimaryAffiliation == "student" &&
Deno.env.get("LOCAL") != "true"
) {
properties.adminOnly.forEach((page) =>
delete context.state.availablePages[page]
);
}
return await context.next();
},
];
@@ -1,147 +1,113 @@
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
interface Promotion {
id: number;
name: string;
}
interface Student {
id: string;
firstName: string;
lastName: string;
promotionId: number;
}
interface Mobility {
id: number;
studentId: string;
startDate: string | null;
endDate: string | null;
weeksCount: number | null;
destinationCountry: string | null;
destinationName: string | null;
mobilityStatus: string;
}
export default function ConsultMobility() { export default function ConsultMobility() {
const [mobilityData, setMobilityData] = useState<MobilityData[]>([]); const [data, setData] = useState<
const [promotions, setPromotions] = useState<Promotion[]>([]); | {
const [selectedPromotion, setSelectedPromotion] = useState<number | "all">("all"); promotions?: Promotion[];
students?: Student[];
mobilities?: Mobility[];
}
| null
>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
console.log("ConsultMobility: Fetching data from API...");
try { try {
console.log("ConsultMobility: Fetching data from API..."); const response = await fetch("/mobility/api/insert_mobility");
const response = await fetch("/mobility/api/insert-mobility"); console.log("ConsultMobility: API response status:", response.status);
if (!response.ok) { if (!response.ok) {
throw new Error(`Error fetching data: ${response.statusText}`); throw new Error(`Error fetching data: ${response.statusText}`);
} }
const result = await response.json(); const result = await response.json();
console.log("ConsultMobility: Data fetched successfully:", result); console.log("ConsultMobility: Data fetched successfully:", result);
setData(result);
setPromotions(result.promotions);
const mergedData = result.students.map((student: any) => {
const existingMobility = result.mobilities.find(
(mobility: any) => mobility.studentId === student.id
);
return {
id: existingMobility ? existingMobility.id : null,
studentId: student.id,
firstName: student.firstName,
lastName: student.lastName,
startDate: existingMobility?.startDate || null,
endDate: existingMobility?.endDate || null,
weeksCount: existingMobility?.weeksCount || null,
destinationCountry: existingMobility?.destinationCountry || null,
destinationName: existingMobility?.destinationName || null,
mobilityStatus: existingMobility?.mobilityStatus || "N/A",
promotionId: student.promotionId,
promotionName: student.promotionName,
attestationFile: existingMobility?.attestationFile || null,
};
});
setMobilityData(mergedData);
} catch (err) { } catch (err) {
console.error("ConsultMobility: Error fetching data:", err); console.error("ConsultMobility: Error fetching data:", err);
setError("Failed to load data. Please try again later."); setError("Failed to load mobility data. Please try again later.");
} }
}; };
fetchData(); fetchData();
}, []); }, []);
const filteredData = if (error) {
selectedPromotion === "all" return <p className="error">{error}</p>;
? mobilityData }
: mobilityData.filter((entry) => entry.promotionId === selectedPromotion);
const downloadFile = (id: number | null) => { if (!data?.promotions) {
if (!id) { return <p>No promotions found.</p>;
alert("No file available for download."); }
return;
}
const downloadUrl = `/mobility/api/download/${id}`;
window.open(downloadUrl, "_blank");
};
return ( return (
<section> <section>
<h2>Consult Mobility</h2> <h2>Consult Mobility</h2>
{error && <p className="error">{error}</p>} {data.promotions.map((promo) => (
<div>
<label htmlFor="promotionSelect">Select Promotion: </label>
<select
id="promotionSelect"
value={selectedPromotion}
onChange={(e) =>
setSelectedPromotion(
e.target.value === "all" ? "all" : Number(e.target.value)
)
}
>
<option value="all">All Promotions</option>
{promotions.map((promo) => (
<option key={promo.id} value={promo.id}>
{promo.name}
</option>
))}
</select>
</div>
{promotions.map((promo) => (
<div key={promo.id}> <div key={promo.id}>
{selectedPromotion === "all" || selectedPromotion === promo.id ? ( <h3>Promotion: {promo.name}</h3>
<> <table>
<h3>Promotion: {promo.name}</h3> <thead>
<table> <tr>
<thead> <th>ID</th>
<tr> <th>First Name</th>
<th>ID</th> <th>Last Name</th>
<th>First Name</th> <th>Start Date</th>
<th>Last Name</th> <th>End Date</th>
<th>Start Date</th> <th>Weeks Count</th>
<th>End Date</th> <th>Destination Country</th>
<th>Weeks Count</th> <th>Destination Name</th>
<th>Destination Country</th> <th>Status</th>
<th>Destination Name</th> </tr>
<th>Status</th> </thead>
<th>Attestation File</th> <tbody>
</tr> {data.students
</thead> ?.filter((student) => student.promotionId === promo.id)
<tbody> .map((student) => {
{filteredData const mobility = data.mobilities?.find((mob) =>
.filter((entry) => entry.promotionId === promo.id) mob.studentId === student.id
.map((entry) => ( );
<tr key={entry.studentId}> return (
<td>{entry.studentId}</td> <tr key={student.id}>
<td>{entry.firstName}</td> <td>{student.id}</td>
<td>{entry.lastName}</td> <td>{student.firstName}</td>
<td>{entry.startDate || "N/A"}</td> <td>{student.lastName}</td>
<td>{entry.endDate || "N/A"}</td> <td>{mobility?.startDate || "N/A"}</td>
<td>{entry.weeksCount || "0"}</td> <td>{mobility?.endDate || "N/A"}</td>
<td>{entry.destinationCountry || "N/A"}</td> <td>{mobility?.weeksCount ?? "N/A"}</td>
<td>{entry.destinationName || "N/A"}</td> <td>{mobility?.destinationCountry || "N/A"}</td>
<td>{entry.mobilityStatus}</td> <td>{mobility?.destinationName || "N/A"}</td>
<td> <td>{mobility?.mobilityStatus || "N/A"}</td>
{entry.attestationFile ? ( </tr>
<button );
onClick={() => downloadFile(entry.id)} })}
> </tbody>
Download </table>
</button>
) : (
"No file"
)}
</td>
</tr>
))}
</tbody>
</table>
</>
) : null}
</div> </div>
))} ))}
</section> </section>
@@ -0,0 +1,75 @@
import { useEffect, useState } from "preact/hooks";
interface Promotion {
id: number;
name: string;
}
interface Student {
id: number;
firstName: string;
lastName: string;
mail: string;
promotionId: number;
promotionName: string;
}
export default function ConsultStudents_test() {
const [data, setData] = useState<
{ promotions: Promotion[]; students: Student[] } | null
>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch("/students/api/insert_students");
if (!response.ok) {
throw new Error(`Error fetching data: ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
console.error("Error fetching data:", err);
setError("Failed to load data. Please try again later.");
}
};
fetchData();
}, []);
return (
<section>
<h2>Consult Students</h2>
{error && <p className="error">{error}</p>}
{data?.promotions.map((promo) => (
<div key={promo.id}>
<h3>Promotion: {promo.id}</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{data.students
.filter((student) => student.promotionId === promo.id)
.map((student) => (
<tr key={student.id}>
<td>{student.id}</td>
<td>{student.firstName}</td>
<td>{student.lastName}</td>
<td>{student.mail}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</section>
);
}
+167 -215
View File
@@ -1,97 +1,117 @@
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
interface Student {
id: string;
firstName: string;
lastName: string;
promotionId: number;
}
interface Promotion {
id: number;
name: string;
}
interface Mobility {
id: number | null;
studentId: string;
startDate: string | null;
endDate: string | null;
weeksCount: number | null;
destinationCountry: string | null;
destinationName: string | null;
mobilityStatus: string;
}
export default function EditMobility() { export default function EditMobility() {
const [mobilityData, setMobilityData] = useState<MobilityData[]>([]); const [data, setData] = useState<
const [promotions, setPromotions] = useState<Promotion[]>([]); | {
const [selectedPromotion, setSelectedPromotion] = useState<number | "all">("all"); promotions?: Promotion[];
students?: Student[];
mobilities?: Mobility[];
}
| null
>(null);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
useEffect(() => { useEffect(() => {
async function fetchMobilityData() { const fetchData = async () => {
console.log("EditMobility: Fetching data from API..."); console.log("EditMobility: Fetching data from API...");
const response = await fetch("/mobility/api/insert-mobility"); try {
const data = await response.json(); const response = await fetch("/mobility/api/insert_mobility");
console.log("EditMobility: Data fetched successfully:", data); console.log("EditMobility: API response status:", response.status);
setPromotions(data.promotions); if (!response.ok) {
throw new Error(`Error fetching data: ${response.statusText}`);
}
const initializedData = data.students.map((student: any) => { const result = await response.json();
const existingMobility = data.mobilities.find( console.log("EditMobility: Data fetched successfully:", result);
(mobility: any) => mobility.studentId === student.id setData(result);
); } catch (err) {
return { console.error("EditMobility: Error fetching data:", err);
id: existingMobility ? existingMobility.id : null, setError("Failed to load mobility data. Please try again later.");
studentId: student.id, }
firstName: student.firstName, };
lastName: student.lastName,
startDate: existingMobility?.startDate || null,
endDate: existingMobility?.endDate || null,
weeksCount: existingMobility?.weeksCount || null,
destinationCountry: existingMobility?.destinationCountry || null,
destinationName: existingMobility?.destinationName || null,
mobilityStatus: existingMobility?.mobilityStatus || "N/A",
attestationFile: existingMobility?.attestationFile || null,
promotionId: student.promotionId,
promotionName: student.promotionName,
};
});
setMobilityData(initializedData);
}
fetchMobilityData(); fetchData();
}, []); }, []);
const handleFileChange = (studentId: string, file: File | null) => { const handleChange = (
if (file && file.type !== "application/pdf") { studentId: string,
alert("Only PDF files are allowed."); field: keyof Mobility,
return; value: string | number | null,
} ) => {
if (!data) return;
setMobilityData((prev) => setData((prevData) => {
prev.map((entry) => if (!prevData) return null;
entry.studentId === studentId ? { ...entry, attestationFile: file } : entry
) const updatedMobilities = prevData.mobilities?.map((mobility) => {
); if (mobility.studentId === studentId) {
const updatedMobility = { ...mobility, [field]: value };
if (field === "startDate" || field === "endDate") {
const startDate = new Date(updatedMobility.startDate || "");
const endDate = new Date(updatedMobility.endDate || "");
if (startDate && endDate && startDate <= endDate) {
const weeks = Math.ceil(
(endDate.getTime() - startDate.getTime()) /
(7 * 24 * 60 * 60 * 1000),
);
updatedMobility.weeksCount = weeks;
} else {
updatedMobility.weeksCount = null;
}
}
return updatedMobility;
}
return mobility;
}) || [];
return { ...prevData, mobilities: updatedMobilities };
});
}; };
const handleSave = async () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
try { try {
console.log("EditMobility: Sending data to API..."); const response = await fetch("/mobility/api/insert_mobility", {
const formData = new FormData();
mobilityData.forEach((entry) => {
formData.append(
"data",
JSON.stringify({
id: entry.id,
studentId: entry.studentId,
startDate: entry.startDate,
endDate: entry.endDate,
destinationCountry: entry.destinationCountry,
destinationName: entry.destinationName,
mobilityStatus: entry.mobilityStatus,
})
);
if (entry.attestationFile instanceof File) {
formData.append(`file_${entry.studentId}`, entry.attestationFile);
}
});
const response = await fetch("/mobility/api/insert-mobility", {
method: "POST", method: "POST",
body: formData, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: data?.mobilities }),
}); });
console.log("EditMobility: Save response status:", response.status);
if (response.ok) { if (response.ok) {
alert("Data saved successfully!"); alert("Data saved successfully!");
console.log("EditMobility: Save response status:", response.status); globalThis.location.reload();
} else { } else {
alert("Failed to save data."); throw new Error(`Failed to save data: ${response.statusText}`);
console.error("EditMobility: Save response status:", response.status);
} }
} catch (error) { } catch (error) {
console.error("EditMobility: Error saving data:", error); console.error("EditMobility: Error saving data:", error);
@@ -101,143 +121,110 @@ export default function EditMobility() {
} }
}; };
const filteredData = if (error) {
selectedPromotion === "all" return <p className="error">{error}</p>;
? mobilityData }
: mobilityData.filter((entry) => entry.promotionId === selectedPromotion);
const groupedData = promotions.map((promo) => ({ if (!data?.promotions) {
promotion: promo.name, return <p>Loading data...</p>;
students: filteredData.filter((entry) => entry.promotionId === promo.id), }
}));
const handleDownload = (id: number) => {
window.open(`/mobility/api/download/${id}`, "_blank");
};
return ( return (
<div> <section>
<h2>Edit Mobility</h2> <h2>Edit Mobility</h2>
{data.promotions.map((promo) => (
<div key={promo.id}>
<h3>Promotion: {promo.name}</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Start Date</th>
<th>End Date</th>
<th>Weeks Count</th>
<th>Destination Country</th>
<th>Destination Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{data.students
?.filter((student) => student.promotionId === promo.id)
.map((student) => {
const mobility = data.mobilities?.find((mob) =>
mob.studentId === student.id
) || {
id: null,
studentId: student.id,
startDate: null,
endDate: null,
weeksCount: null,
destinationCountry: null,
destinationName: null,
mobilityStatus: "N/A",
};
<div> return (
<label htmlFor="promotionSelect">Select Promotion: </label> <tr key={student.id}>
<select <td>{student.id}</td>
id="promotionSelect" <td>{student.firstName}</td>
value={selectedPromotion} <td>{student.lastName}</td>
onChange={(e) =>
setSelectedPromotion(
e.target.value === "all" ? "all" : Number(e.target.value)
)
}
>
<option value="all">All Promotions</option>
{promotions.map((promo) => (
<option key={promo.id} value={promo.id}>
{promo.name}
</option>
))}
</select>
</div>
{groupedData.map((group) => (
<div key={group.promotion}>
{group.students.length > 0 && (
<>
<h3>Promotion: {group.promotion}</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Start Date</th>
<th>End Date</th>
<th>Weeks Count</th>
<th>Destination Country</th>
<th>Destination Name</th>
<th>Status</th>
<th>Attestation File</th>
</tr>
</thead>
<tbody>
{group.students.map((entry) => (
<tr key={entry.studentId}>
<td>{entry.studentId}</td>
<td>{entry.firstName}</td>
<td>{entry.lastName}</td>
<td> <td>
<input <input
type="date" type="date"
value={entry.startDate || ""} value={mobility.startDate || ""}
onChange={(e) => onChange={(e) =>
setMobilityData((prev) => handleChange(
prev.map((data) => student.id,
data.studentId === entry.studentId "startDate",
? { ...data, startDate: e.target.value } e.target.value,
: data )}
)
)
}
/> />
</td> </td>
<td> <td>
<input <input
type="date" type="date"
value={entry.endDate || ""} value={mobility.endDate || ""}
onChange={(e) => onChange={(e) =>
setMobilityData((prev) => handleChange(student.id, "endDate", e.target.value)}
prev.map((data) =>
data.studentId === entry.studentId
? { ...data, endDate: e.target.value }
: data
)
)
}
/> />
</td> </td>
<td>{entry.weeksCount || "0"}</td> <td>{mobility.weeksCount ?? "N/A"}</td>
<td> <td>
<input <input
type="text" type="text"
value={entry.destinationCountry || ""} value={mobility.destinationCountry || ""}
onChange={(e) => onChange={(e) =>
setMobilityData((prev) => handleChange(
prev.map((data) => student.id,
data.studentId === entry.studentId "destinationCountry",
? { ...data, destinationCountry: e.target.value } e.target.value,
: data )}
)
)
}
/> />
</td> </td>
<td> <td>
<input <input
type="text" type="text"
value={entry.destinationName || ""} value={mobility.destinationName || ""}
onChange={(e) => onChange={(e) =>
setMobilityData((prev) => handleChange(
prev.map((data) => student.id,
data.studentId === entry.studentId "destinationName",
? { ...data, destinationName: e.target.value } e.target.value,
: data )}
)
)
}
/> />
</td> </td>
<td> <td>
<select <select
value={entry.mobilityStatus} value={mobility.mobilityStatus}
onChange={(e) => onChange={(e) =>
setMobilityData((prev) => handleChange(
prev.map((data) => student.id,
data.studentId === entry.studentId "mobilityStatus",
? { ...data, mobilityStatus: e.target.value } e.target.value,
: data )}
)
)
}
> >
<option value="N/A">N/A</option> <option value="N/A">N/A</option>
<option value="Planned">Planned</option> <option value="Planned">Planned</option>
@@ -246,51 +233,16 @@ export default function EditMobility() {
<option value="Validated">Validated</option> <option value="Validated">Validated</option>
</select> </select>
</td> </td>
<td>
{entry.attestationFile ? (
<>
<button onClick={() => handleDownload(entry.id!)}>
Download
</button>
<button
onClick={() =>
setMobilityData((prev) =>
prev.map((data) =>
data.studentId === entry.studentId
? { ...data, attestationFile: null }
: data
)
)
}
>
Delete
</button>
</>
) : (
<input
type="file"
accept=".pdf"
onChange={(e) =>
handleFileChange(
entry.studentId,
e.target.files?.[0] || null
)
}
/>
)}
</td>
</tr> </tr>
))} );
</tbody> })}
</table> </tbody>
</> </table>
)}
</div> </div>
))} ))}
<button type="button" onClick={handleSave} disabled={isSaving}>
<button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Confirm"} {isSaving ? "Saving..." : "Confirm"}
</button> </button>
</div> </section>
); );
} }
+2 -1
View File
@@ -8,8 +8,9 @@ const properties: AppProperties = {
index: "Homepage", index: "Homepage",
overview: "Mobility overview", overview: "Mobility overview",
edit_mobility: "Mobility management", edit_mobility: "Mobility management",
consult_students_test: "Test consult students",
}, },
adminOnly: ["edit_mobility"], adminOnly: ["edit_mobility", "consult_students_test"],
}; };
export default properties; export default properties;
-43
View File
@@ -1,43 +0,0 @@
import { Handlers } from "$fresh/server.ts";
import connect from "$root/databases/connect.ts";
export const handler: Handlers = {
async GET(request) {
try {
console.log("API /mobility/api/download/:id GET called");
const url = new URL(request.url);
const id = url.pathname.split("/").pop();
if (!id) {
return new Response("Invalid request: Missing ID", { status: 400 });
}
console.log("Connecting to mobility database...");
using connection = connect("mobility");
console.log("Connected to databases.");
const query = connection.database.prepare(
`SELECT attestationFile FROM mobility WHERE id = ?`
);
const result = query.get(id);
if (!result || !result.attestationFile) {
return new Response("No file found for the given ID", { status: 404 });
}
const fileBuffer = result.attestationFile;
return new Response(fileBuffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="attestation_${id}.pdf"`,
},
});
} catch (error) {
console.error("Error fetching file:", error);
return new Response("Failed to fetch file", { status: 500 });
}
},
};
@@ -1,131 +0,0 @@
import { Handlers } from "$fresh/server.ts";
import connect from "$root/databases/connect.ts";
export const handler: Handlers = {
// deno-lint-ignore require-await
async GET() {
try {
using connection = connect("mobility");
const mobilities = connection.database.prepare(
`SELECT
mobility.id,
mobility.studentId,
mobility.startDate,
mobility.endDate,
mobility.weeksCount,
mobility.destinationCountry,
mobility.destinationName,
mobility.mobilityStatus,
mobility.attestationFile -- Inclure le fichier
FROM mobility`
).all();
const students = connection.database.prepare(
`SELECT
students.userId AS id,
students.firstName,
students.lastName,
students.promotionId AS promotionId,
promotions.name AS promotionName
FROM students.students
LEFT JOIN students.promotions ON students.promotionId = promotions.id`
).all();
const promotions = connection.database.prepare(
`SELECT id, name FROM students.promotions`
).all();
return new Response(
JSON.stringify({ mobilities, students, promotions }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Error fetching mobility data:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
async POST(request) {
console.log("API /mobility/api/insert-mobility POST called");
try {
const formData = await request.formData();
const dataEntries = formData.getAll("data").map((item) => JSON.parse(item as string));
console.log("Parsed data entries:", dataEntries);
const fileMap: Record<string, Uint8Array> = {};
for (const [key, value] of formData.entries()) {
if (key.startsWith("file_") && value instanceof File) {
const studentId = key.split("_")[1];
const file = value as File;
fileMap[studentId] = new Uint8Array(await file.arrayBuffer());
console.log(`File processed for studentId ${studentId}`);
}
}
using connection = connect("mobility");
const insertQuery = connection.database.prepare(
`INSERT INTO mobility (
id, studentId, startDate, endDate, weeksCount, destinationCountry, destinationName, mobilityStatus, attestationFile
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
startDate = excluded.startDate,
endDate = excluded.endDate,
weeksCount = excluded.weeksCount,
destinationCountry = excluded.destinationCountry,
destinationName = excluded.destinationName,
mobilityStatus = excluded.mobilityStatus,
attestationFile = excluded.attestationFile`
);
for (const mobility of dataEntries) {
const {
id = null,
studentId,
startDate,
endDate,
destinationCountry,
destinationName,
mobilityStatus = "N/A",
} = mobility;
let calculatedWeeksCount = null;
if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
if (start <= end) {
const differenceInDays = Math.ceil(
(end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)
);
calculatedWeeksCount = Math.floor(differenceInDays / 7);
}
}
const attestationFile = fileMap[studentId] || null;
console.log(`Inserting/Updating mobility for studentId: ${studentId}`);
insertQuery.run(
id,
studentId,
startDate,
endDate,
calculatedWeeksCount,
destinationCountry,
destinationName,
mobilityStatus,
attestationFile
);
}
console.log("Mobility data inserted/updated successfully.");
return new Response("Data inserted/updated successfully", { status: 200 });
} catch (error) {
console.error("Error inserting mobility data:", error);
return new Response("Failed to insert/update data", { status: 500 });
}
},
};
@@ -0,0 +1,168 @@
import { Handlers } from "$fresh/server.ts";
import { Database } from "@db/sqlite";
export const handler: Handlers = {
// deno-lint-ignore require-await
async GET() {
try {
console.log("Connecting to mobility database...");
const connection = new Database("databases/data/mobility.db", {
create: false,
});
connection.run(
"ATTACH DATABASE 'databases/data/students.db' AS students",
);
console.log("Connected to databases.");
const students = connection.prepare(
`SELECT
students.userId AS id,
students.firstName,
students.lastName,
students.promotionId AS promotionId,
promotions.name AS promotionName
FROM students.students
LEFT JOIN students.promotions ON students.promotionId = promotions.id`,
).all();
const mobilities = connection.prepare(
`SELECT
mobility.id,
mobility.studentId,
mobility.startDate,
mobility.endDate,
mobility.weeksCount,
mobility.destinationCountry,
mobility.destinationName,
mobility.mobilityStatus
FROM mobility`,
).all();
const promotions = connection.prepare(
`SELECT id, name FROM students.promotions`,
).all();
connection.close();
return new Response(
JSON.stringify({ mobilities, students, promotions }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Error fetching mobility data:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
async POST(request) {
console.log("API /mobility/api/insert_mobility POST called");
try {
const body = await request.json();
const { data } = body;
if (!Array.isArray(data)) {
throw new Error("Invalid request body");
}
console.log("Connecting to mobility database...");
const connection = new Database("databases/data/mobility.db", {
create: false,
});
console.log("Attaching students database...");
connection.run(
"ATTACH DATABASE 'databases/data/students.db' AS students",
);
console.log("Students database attached successfully.");
const insertQuery = connection.prepare(
`INSERT INTO mobility (
id, studentId, startDate, endDate, weeksCount, destinationCountry, destinationName, mobilityStatus
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
startDate = excluded.startDate,
endDate = excluded.endDate,
weeksCount = excluded.weeksCount,
destinationCountry = excluded.destinationCountry,
destinationName = excluded.destinationName,
mobilityStatus = excluded.mobilityStatus`,
);
for (const mobility of data) {
const {
id,
studentId,
startDate,
endDate,
weeksCount,
destinationCountry,
destinationName,
mobilityStatus = "N/A",
} = mobility;
console.log("Processing mobility data:", mobility);
const studentExists = connection
.prepare(
`SELECT COUNT(*) AS count FROM students.students WHERE userId = ?`,
)
.get(studentId);
console.log(`Student ${studentId} exists:`, studentExists.count > 0);
if (studentExists.count === 0) {
console.warn(`Skipping mobility for unknown studentId: ${studentId}`);
continue;
}
let calculatedWeeksCount = weeksCount;
if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
if (start <= end) {
calculatedWeeksCount = Math.ceil(
(end.getTime() - start.getTime()) / (7 * 24 * 60 * 60 * 1000),
);
} else {
calculatedWeeksCount = null;
}
}
console.log("Executing SQL insert/update query for:", {
id,
studentId,
startDate,
endDate,
calculatedWeeksCount,
destinationCountry,
destinationName,
mobilityStatus,
});
insertQuery.run(
id,
studentId,
startDate,
endDate,
calculatedWeeksCount,
destinationCountry,
destinationName,
mobilityStatus,
);
}
connection.close();
console.log("Mobility data inserted/updated successfully.");
return new Response("Data inserted/updated successfully", {
status: 200,
});
} catch (error) {
console.error("Error inserting mobility data:", error);
return new Response("Failed to insert/update data", { status: 500 });
}
},
};
@@ -0,0 +1,21 @@
import ConsultStudents_test from "$root/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx";
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/routes/_middleware.ts";
//import EditStudents from "../(_islands)/EditStudents.tsx";
// deno-lint-ignore require-await
async function Mobility(_request: Request, _context: FreshContext<State>) {
return (
<>
<h1>Test consult students</h1>
<ConsultStudents_test />
</>
);
}
export const config = getPartialsConfig();
export default makePartials(Mobility);
+1 -1
View File
@@ -10,7 +10,7 @@ import { State } from "$root/routes/_middleware.ts";
async function Mobility(_request: Request, _context: FreshContext<State>) { async function Mobility(_request: Request, _context: FreshContext<State>) {
return ( return (
<> <>
<h1>Mobility overview</h1> <h1>Edit mobility</h1>
<ConsultMobility /> <ConsultMobility />
</> </>
); );
-21
View File
@@ -1,21 +0,0 @@
interface Promotion {
id: number;
name: string;
}
interface MobilityData {
id: number | null;
studentId: string;
firstName: string;
lastName: string;
startDate: string | null;
endDate: string | null;
weeksCount: number | null;
destinationCountry: string | null;
destinationName: string | null;
mobilityStatus: string;
promotionId: number;
promotionName: string;
//attestationFile: File | null;
}
@@ -0,0 +1,30 @@
import Student from "$root/routes/(apps)/students/(_components)/Student.tsx";
type PromotionProps = { students: Student[]; promo: Promotion };
export default function Promotion(props: PromotionProps) {
if (!props.promo) {
return <p>Unable to find user in database.</p>;
}
return (
<div key={props.promo.id}>
<h3>Promotion {props.promo.endyear}</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{props.students
.filter((student) => student.promotionId === props.promo.id)
.map((student) => <Student key={student.id} student={student} />)}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,31 @@
import { CasContent } from "$root/defaults/interfaces.ts";
type SelfPortraitProps = { self: CasContent };
const regex =
/^(?<year>\d{4})(?<month>\d{2})(?<date>\d{2})(?<hours>\d{2})(?<minutes>\d{2})(?<seconds>\d{2})Z$/;
export default function SelfPortrait(props: SelfPortraitProps) {
const { year, month, date, hours, minutes, seconds } = props.self
.amuDateValidation.match(regex)!.groups!;
const validationIsoDate =
`${year}-${month}-${date}T${hours}:${minutes}:${seconds}Z`;
const validationDate = new Date(validationIsoDate);
return (
<div id="self-portrait">
<div>Identity</div>
<div>{props.self.supannCivilite} {props.self.displayName}</div>
<div>Student number</div>
<div>{props.self.uid}</div>
<div>amU mail</div>
<div>{props.self.mail}</div>
<div>First amU registration</div>
<div>{validationDate.toLocaleString()}</div>
<div>amU class code</div>
<div>{props.self.supannEtuEtape}</div>
</div>
);
}
@@ -0,0 +1,13 @@
type StudentProps = { student: Student; promo?: number };
export default function Student(props: StudentProps) {
return (
<tr key={props.student.userId}>
<td>{props.student.userId}</td>
<td>{props.student.firstName}</td>
<td>{props.student.lastName}</td>
<td>{props.student.mail}</td>
{props.promo && <td>{props.promo}</td>}
</tr>
);
}
@@ -1,75 +1,45 @@
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import Promotion from "$root/routes/(apps)/students/(_components)/Promotion.tsx";
interface Promotion { type SingleUserResponse = { promo: Promotion; student: Student };
id: number; type ManyUsersResponse = { promos: Promotion[]; students: Student[] };
name: string;
}
interface Student { type APIResponse = SingleUserResponse | ManyUsersResponse;
userId: string;
firstName: string;
lastName: string;
mail: string;
promotionId: number;
}
export default function ConsultStudents() { export default function ConsultStudents() {
const [data, setData] = useState< const [data, setData] = useState<APIResponse | null>(null);
{ promotions: Promotion[]; students: Student[] } | null
>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { const response = await fetch("/students/api/students");
const response = await fetch("/students/api/insert_students"); if (!response.ok) {
if (!response.ok) {
throw new Error(`Error fetching data: ${response.statusText}`);
}
const result = await response.json();
console.log("Fetched data:", result);
setData(result);
} catch (err) {
console.error("Error fetching data:", err);
setError("Failed to load data. Please try again later."); setError("Failed to load data. Please try again later.");
} }
const result: APIResponse = await response.json();
setData(result);
}; };
fetchData(); fetchData();
}, []); }, []);
return ( return (
<section> <>
<h2>Consult Students</h2>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{data?.promotions.map((promo) => ( {data && ((Object.hasOwn(data, "student"))
<div key={promo.id}> ? (
<h3>Promotion: {promo.name}</h3> <Promotion
<table> students={[(data as SingleUserResponse).student]}
<thead> promo={(data as SingleUserResponse).promo}
<tr> />
<th>ID</th> )
<th>First Name</th> : (data as ManyUsersResponse).promos.map((promo) => (
<th>Last Name</th> <Promotion
<th>Email</th> students={(data as ManyUsersResponse).students}
</tr> promo={promo}
</thead> />
<tbody> )))}
{data.students </>
.filter((student) => student.promotionId === promo.id)
.map((student) => (
<tr key={student.userId}>
<td>{student.userId}</td>
<td>{student.firstName}</td>
<td>{student.lastName}</td>
<td>{student.mail}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</section>
); );
} }
@@ -1,75 +1,111 @@
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts" // @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
import { useSignal } from "@preact/signals"; import { Signal, useSignal } from "@preact/signals";
export default function UploadStudents() { /**
const statusMessage = useSignal<string>(""); * Create a new handler for file change that displays
const fileData = useSignal<File | null>(null); * messages in statusMessage and gets file data in fileData.
* @param statusMessage The status message signal.
const handleFileChange = (event: Event) => { * @param fileData The file data signal.
* @returns The file change handler.
*/
function getFileChangeHandler(
statusMessage: Signal<string>,
fileData: Signal<File | null>,
): (event: Event) => void {
/**
* Handle file change.
* @param event The file change event.
*/
return (event: Event) => {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) { if (input.files && input.files.length > 0) {
fileData.value = input.files[0]; fileData.value = input.files[0];
statusMessage.value = "File selected: " + input.files[0].name; statusMessage.value = `File selected: ${input.files[0].name}`;
} else { } else {
fileData.value = null; fileData.value = null;
statusMessage.value = "No file selected"; statusMessage.value = "No file selected";
} }
}; };
}
const confirmUpload = () => { /**
* Create a new handler that sends data file to server.
* @param statusMessage The status message signal.
* @param fileData The file data signal.
* @returns The file confirmation handler.
*/
function getUploadConfirmationFunction(
statusMessage: Signal<string>,
fileData: Signal<File | null>,
): () => void {
/**
* Add students to database.
* @returns Confirm upload of students.
*/
return () => {
if (!fileData.value) { if (!fileData.value) {
statusMessage.value = "Please select a file before confirming upload."; statusMessage.value = "Please select a file before confirming upload.";
return; return;
} }
try { const reader = new FileReader();
const reader = new FileReader();
reader.onload = async (e) => { /**
const arrayBuffer = e.target?.result as ArrayBuffer; * Send all data to the server.
const workbook = XLSX.read(arrayBuffer, { type: "array" }); * @param event The finished progress event.
*/
reader.onload = async (event: ProgressEvent<FileReader>) => {
const arrayBuffer = event.target!.result as ArrayBuffer;
const workbook = XLSX.read(arrayBuffer, { type: "array" });
let allOK = true;
for (const sheetName of workbook.SheetNames) { for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName]; const sheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(sheet, { const data = XLSX.utils.sheet_to_json(sheet, {
header: ["Identifiant", "Nom", "Prénom", "Mail"], header: ["userId", "lastName", "firstName", "mail"],
range: 1, // Ignorer les en-têtes range: 1,
}); });
console.log(`Data from sheet ${sheetName}:`, data); const response = await fetch("/students/api/students", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ promoName: sheetName, data }),
});
const response = await fetch("/students/api/insert_students", { if (!response.ok) {
method: "POST", allOK = false;
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ promoName: sheetName, data }),
});
if (!response.ok) {
throw new Error(`Failed to insert data for promotion ${sheetName}`);
}
} }
}
statusMessage.value = "Data uploaded and inserted successfully!"; statusMessage.value = allOK
}; ? "Failed to insert all data."
: "Data uploaded and inserted successfully!";
};
reader.onerror = () => { /**
statusMessage.value = "Error reading the file."; * Display error message if any.
}; */
reader.onerror = () => {
statusMessage.value = "Error reading the file.";
};
reader.readAsArrayBuffer(fileData.value); reader.readAsArrayBuffer(fileData.value);
} catch (error) {
console.error("Error uploading file:", error);
statusMessage.value = "An unexpected error occurred during upload.";
}
}; };
}
export default function UploadStudents() {
const statusMessage = useSignal<string>("");
const fileData = useSignal<File | null>(null);
const handleFileChange = getFileChangeHandler(statusMessage, fileData);
const confirmUpload = getUploadConfirmationFunction(statusMessage, fileData);
return ( return (
<div> <>
<h2>Upload Students</h2>
<input type="file" accept=".xlsx, .xls" onChange={handleFileChange} /> <input type="file" accept=".xlsx, .xls" onChange={handleFileChange} />
<button onClick={confirmUpload}>Confirm Upload</button> <button type="button" onClick={confirmUpload}>Confirm Upload</button>
<p>{statusMessage.value}</p> <p>{statusMessage.value}</p>
</div> </>
); );
} }
-1
View File
@@ -5,7 +5,6 @@ const properties: AppProperties = {
icon: "badge", icon: "badge",
pages: { pages: {
index: "Homepage", index: "Homepage",
overview: "Students overview",
upload: "Upload students", upload: "Upload students",
consult: "Consult students", consult: "Consult students",
}, },
@@ -1,83 +0,0 @@
import { Handlers } from "$fresh/server.ts";
import connect from "$root/databases/connect.ts";
export const handler: Handlers = {
// deno-lint-ignore require-await
async GET() {
try {
using connection = connect("students");
const promotions = connection.database.prepare(
"select id, name from promotions",
).all();
const students = connection.database
.prepare(
`select userId, firstName, lastName, mail, promotionId from students`,
)
.all();
return new Response(
JSON.stringify({ promotions, students }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Error fetching data:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
async POST(request) {
console.log("API /students/api/insert_students called");
try {
const body = await request.json();
const { data, promoName } = body;
console.log("Received data:", { promoName, data });
if (!promoName || !Array.isArray(data)) {
throw new Error("Invalid request body");
}
using connection = connect("students");
connection.database.prepare(
"INSERT OR IGNORE INTO promotions (name) VALUES (?)",
).run(promoName);
const promoIdRow: { id: number } = connection.database
.prepare("SELECT id FROM promotions WHERE name = ?")
.get(promoName)!;
const promoId = promoIdRow.id;
console.log(`Promotion ID for "${promoName}":`, promoId);
const insertQuery = connection.database.prepare(
`INSERT INTO students
(userId, firstName, lastName, mail, promotionId)
VALUES (?, ?, ?, ?, ?)`,
);
for (const student of data) {
console.log("Inserting student:", student);
insertQuery.run(
student.Identifiant,
student.Nom,
student["Prénom"],
student.Mail,
promoId,
);
}
console.log("All data inserted successfully");
return new Response("Data inserted successfully", { status: 201 });
} catch (error) {
console.error("Error inserting data:", error);
return new Response("Failed to insert data", { status: 500 });
}
},
};
+151
View File
@@ -0,0 +1,151 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import connect from "$root/databases/connect.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { Database } from "@db/sqlite";
/**
* Gets itself from the database.
* @param database The database connection
* @param userId The user ID.
* @returns Itself from the database.
*/
function getItself(
database: Database,
userId: string,
): { student: Student | null; promo: Promotion | null } {
const studentQuery = "select * from students where userId = ?";
const student: Student | undefined = database.prepare(studentQuery).get(
userId,
);
if (!student) {
return { student: null, promo: null };
}
const promoQuery = "select * from promotions where id = ?";
const promo: Promotion | undefined = database.prepare(promoQuery).get(
student.promotionId,
);
return { student, promo: promo ?? null };
}
/**
* Gets itself from the database.
* @param database The database connexion
* @param userId The user ID.
* @returns Itself from the database.
*/
function getAll(
database: Database,
): { students: Student[]; promos: Promotion[] } {
const studentsQuery = `
select userId, firstName, lastName, mail, promotionId
from students inner join promotions
on students.promotionId = promotions.id
where promotions.current < 6`;
const students: Student[] = database.prepare(studentsQuery).all();
const promosQuery = "select * from promotions where promotions.current < 6";
const promos: Promotion[] | undefined = database.prepare(promosQuery).all();
return { students, promos };
}
/**
* Add users to the database.
* @param database The database connexion
* @param students The students to add
* @param promoId The promotion id.
*/
function addStudents(database: Database, students: Student[], promoId: string) {
const query = `
INSERT INTO students
(userId, firstName, lastName, mail, promotionId)
VALUES (?, ?, ?, ?, ?)`;
const statement = database.prepare(query);
for (const student of students) {
statement.run(
student.userId,
student.firstName,
student.lastName,
student.mail,
promoId,
);
}
}
export const handler: Handlers<null, AuthenticatedState> = {
/**
* The students the user can see.
* @param _request The HTTP request.
* @param _context The context with authenticated state.
* @returns All students our user can see.
*/
// deno-lint-ignore require-await
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
using connection = connect("students");
const database = connection.database;
if (context.state.session.eduPersonPrimaryAffiliation == "student") {
return new Response(
JSON.stringify(getItself(database, context.state.session.uid)),
{
headers: {
"content-type": "application/json",
},
},
);
}
return new Response(
JSON.stringify(getAll(database)),
{
headers: {
"content-type": "application/json",
},
},
);
},
/**
* Add students in the database.
* @param request The HTTP request.
* @param _context The Fresh context.
* @returns HTTP 201 on successful insert.
*/
async POST(
request: Request,
_context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const { students, promo }: { students: Student[]; promo: string } =
await request.json();
if (!promo || !promo.match(/^\d{4}-\dA$/) || !Array.isArray(students)) {
return new Response(null, { status: 400 });
}
using connection = connect("students");
const database = connection.database;
const { endyear, current } = promo.match(
/^(?<endyear>\d{4})-(?<current>\d)A$/,
)?.groups!;
database.prepare(
"insert or ignore into promotions (endyear, current) values (?, ?)",
).run(endyear, current);
const { id: promoId }: { id: string } = database
.prepare("select id from promotions where endyear = ? and current = ?")
.get(endyear, current)!;
addStudents(database, students, promoId);
return new Response(null, { status: 201 });
},
};
@@ -1,17 +1,16 @@
import ConsultStudents from "$root/routes/(apps)/students/(_islands)/ConsultStudents.tsx"; import ConsultStudents from "../../(_islands)/ConsultStudents.tsx";
import { import {
getPartialsConfig, getPartialsConfig,
makePartials, makePartials,
} from "$root/defaults/makePartials.tsx"; } from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts"; import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/routes/_middleware.ts"; import { State } from "$root/defaults/interfaces.ts";
//import EditStudents from "../(_islands)/EditStudents.tsx";
// deno-lint-ignore require-await // deno-lint-ignore require-await
async function Students(_request: Request, _context: FreshContext<State>) { async function Students(_request: Request, _context: FreshContext<State>) {
return ( return (
<> <>
<h1>Manage Promotions</h1> <h2>Consult students</h2>
<ConsultStudents /> <ConsultStudents />
</> </>
); );
@@ -1,17 +1,16 @@
import UploadStudents from "$root/routes/(apps)/students/(_islands)/UploadStudents.tsx"; import UploadStudents from "../../(_islands)/UploadStudents.tsx";
import { import {
getPartialsConfig, getPartialsConfig,
makePartials, makePartials,
} from "$root/defaults/makePartials.tsx"; } from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts"; import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/routes/_middleware.ts"; import { State } from "$root/defaults/interfaces.ts";
//import EditStudents from "../(_islands)/EditStudents.tsx";
// deno-lint-ignore require-await // deno-lint-ignore require-await
async function Students(_request: Request, _context: FreshContext<State>) { async function Students(_request: Request, _context: FreshContext<State>) {
return ( return (
<> <>
<h1>Manage Promotions</h1> <h2>Upload Students</h2>
<UploadStudents /> <UploadStudents />
</> </>
); );
+9 -2
View File
@@ -3,11 +3,18 @@ import {
makePartials, makePartials,
} from "$root/defaults/makePartials.tsx"; } from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts"; import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/routes/_middleware.ts"; import { State } from "$root/defaults/interfaces.ts";
import SelfPortrait from "$root/routes/(apps)/students/(_components)/SelfPortrait.tsx";
// deno-lint-ignore require-await // deno-lint-ignore require-await
export async function Index(_request: Request, context: FreshContext<State>) { export async function Index(_request: Request, context: FreshContext<State>) {
return <h2>Welcome to {context.state.session?.displayName}.</h2>; return (
<>
<h2>Welcome {context.state.session?.givenName}!</h2>
<h3>Your amU identity</h3>
<SelfPortrait self={context.state.session!} />
</>
);
} }
export const config = getPartialsConfig(); export const config = getPartialsConfig();
@@ -1,17 +0,0 @@
import { Partial } from "$fresh/runtime.ts";
import { RouteConfig } from "$fresh/server.ts";
type ModulesProps = Record<string | number | symbol, never>;
export const config: RouteConfig = {
skipAppWrapper: true,
skipInheritedLayouts: true,
};
export default function Modules(_props: ModulesProps) {
return (
<Partial name="body">
<a href="students" f-partial={"notes/partials"}>students</a>
</Partial>
);
}
+13
View File
@@ -0,0 +1,13 @@
interface Student {
userId: string;
firstName: string;
lastName: string;
mail: string;
promotionId: number;
}
interface Promotion {
id: number;
endyear: number;
current: number;
}
+1
View File
@@ -27,6 +27,7 @@ export default async function App(
<link rel="stylesheet" href="/styles/main.css" /> <link rel="stylesheet" href="/styles/main.css" />
<link rel="stylesheet" href="/styles/app.css" /> <link rel="stylesheet" href="/styles/app.css" />
<link rel="stylesheet" href="styles/app-cards.css" /> <link rel="stylesheet" href="styles/app-cards.css" />
<link rel="stylesheet" href="styles/students.css" />
</head> </head>
<body f-client-nav> <body f-client-nav>
<Header link={link} /> <Header link={link} />
+7 -2
View File
@@ -3,7 +3,7 @@
padding: 1em 0; padding: 1em 0;
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
gap: 1em; gap: 4em;
} }
#app > #app-body { #app > #app-body {
@@ -16,7 +16,7 @@
} }
#app > nav > a { #app > nav > a {
padding: 0.25em 0.5em; padding: 0.5em 4em 0.5em 1em;
color: light-dark(var(--light-foreground), var(--dark-foreground)); color: light-dark(var(--light-foreground), var(--dark-foreground));
} }
@@ -57,5 +57,10 @@
#app { #app {
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
grid-template-columns: none; grid-template-columns: none;
gap: 1em;
}
#app > nav > a {
padding: 0.5em 1em;
} }
} }
+15
View File
@@ -0,0 +1,15 @@
#self-portrait {
display: grid;
gap: 1em;
grid-template-columns: auto 1fr;
}
#self-portrait > div:nth-child(2n+1) {
font-weight: var(--font-weight-bold);
}
@media screen and (max-width: 1024px) {
#self-portrait {
grid-template-columns: 1fr;
}
}
View File
+123
View File
@@ -0,0 +1,123 @@
// Mock de fetch() pour les tests — supporte méthodes HTTP et status codes
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
export interface MockRoute {
method?: HttpMethod;
status?: number;
body?: unknown;
headers?: Record<string, string>;
}
// deno-lint-ignore no-explicit-any
let _originalFetch: ((input: any, init?: any) => Promise<Response>) | null =
null;
let _calls: { url: string; method: string; body?: unknown }[] = [];
/**
* Remplace globalThis.fetch par un mock configurable.
*
* Usage simple (GET 200 par défaut) :
* mockFetch({ "/students": studentsData })
*
* Usage avancé (méthode + status) :
* mockFetch({ "/students": { method: "POST", status: 201, body: newStudent } })
*/
export function mockFetch(
routes: Record<string, unknown | MockRoute>,
): void {
_originalFetch = globalThis.fetch;
_calls = [];
globalThis.fetch = (
input: string | URL | Request,
init?: RequestInit,
): Promise<Response> => {
const url = typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const method = (init?.method ?? "GET").toUpperCase();
// Parse le body si présent
let reqBody: unknown = undefined;
if (init?.body) {
try {
reqBody = JSON.parse(init.body as string);
} catch {
reqBody = init.body;
}
}
_calls.push({ url, method, body: reqBody });
for (const [pattern, config] of Object.entries(routes)) {
if (!url.includes(pattern)) continue;
// Config simple : la valeur est directement le body de réponse (GET 200)
if (!isRouteConfig(config)) {
return new Response(JSON.stringify(config), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// Config avancée : vérifier la méthode si spécifiée
if (config.method && config.method !== method) continue;
const status = config.status ?? 200;
// 204 : pas de body
if (status === 204) {
return new Response(null, { status: 204 });
}
return new Response(
config.body !== undefined ? JSON.stringify(config.body) : null,
{
status,
headers: {
"Content-Type": "application/json",
...config.headers,
},
},
);
}
return new Response(JSON.stringify({ error: "Not Found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
};
}
/**
* Restaure le fetch original.
*/
export function restoreFetch(): void {
if (_originalFetch) {
globalThis.fetch = _originalFetch;
_originalFetch = null;
}
_calls = [];
}
/**
* Retourne la liste des appels fetch interceptés.
*/
export function getFetchCalls(): {
url: string;
method: string;
body?: unknown;
}[] {
return [..._calls];
}
function isRouteConfig(value: unknown): value is MockRoute {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const v = value as Record<string, unknown>;
return "status" in v || "method" in v || "body" in v;
}
+122
View File
@@ -0,0 +1,122 @@
// Mock de la couche Drizzle pour les tests unitaires/intégration
// Permet de tester les handlers sans connexion PostgreSQL
export interface MockQueryResult<T> {
rows: T[];
}
export interface MockDbConfig {
// Table name → array of rows
// deno-lint-ignore no-explicit-any
tables: Record<string, Record<string, any>[]>;
}
/**
* Crée un mock de la DB Drizzle.
* Simule select/insert/update/delete avec un store en mémoire.
*
* Usage :
* ```ts
* const db = createMockDb({
* tables: {
* students: [{ numEtud: 21212006, nom: "Dupont", ... }],
* notes: [],
* }
* });
*
* // Lire toutes les lignes d'une table
* const rows = db.getTable("students");
*
* // Insérer
* db.insert("students", { numEtud: 21212009, nom: "Test", ... });
*
* // Trouver par clé
* const student = db.findOne("students", (r) => r.numEtud === 21212006);
*
* // Supprimer
* db.deleteWhere("students", (r) => r.numEtud === 21212006);
* ```
*/
export function createMockDb(config: MockDbConfig) {
// Deep clone pour éviter les mutations entre tests
// deno-lint-ignore no-explicit-any
const tables: Record<string, Record<string, any>[]> = {};
for (const [name, rows] of Object.entries(config.tables)) {
tables[name] = rows.map((r) => ({ ...r }));
}
return {
/** Retourne toutes les lignes d'une table */
getTable<T = Record<string, unknown>>(name: string): T[] {
return (tables[name] ?? []) as T[];
},
/** Retourne les lignes qui matchent le filtre */
findMany<T = Record<string, unknown>>(
name: string,
predicate: (row: T) => boolean,
): T[] {
return (this.getTable<T>(name)).filter(predicate);
},
/** Retourne la première ligne qui matche, ou undefined */
findOne<T = Record<string, unknown>>(
name: string,
predicate: (row: T) => boolean,
): T | undefined {
return (this.getTable<T>(name)).find(predicate);
},
/** Insère une ligne dans la table */
insert<T = Record<string, unknown>>(name: string, row: T): T {
if (!tables[name]) tables[name] = [];
const copy = { ...row } as T;
// deno-lint-ignore no-explicit-any
tables[name].push(copy as any);
return copy;
},
/** Met à jour les lignes qui matchent le prédicat */
updateWhere<T = Record<string, unknown>>(
name: string,
predicate: (row: T) => boolean,
updates: Partial<T>,
): number {
const rows = this.getTable<T>(name);
let count = 0;
for (const row of rows) {
if (predicate(row)) {
Object.assign(row as Record<string, unknown>, updates);
count++;
}
}
return count;
},
/** Supprime les lignes qui matchent le prédicat */
deleteWhere<T = Record<string, unknown>>(
name: string,
predicate: (row: T) => boolean,
): number {
const before = (tables[name] ?? []).length;
tables[name] = (tables[name] ?? []).filter(
(r) => !predicate(r as unknown as T),
);
return before - tables[name].length;
},
/** Vide une table */
clear(name: string): void {
tables[name] = [];
},
/** Vide toutes les tables */
reset(): void {
for (const name of Object.keys(tables)) {
tables[name] = [];
}
},
};
}
export type MockDb = ReturnType<typeof createMockDb>;
+137
View File
@@ -0,0 +1,137 @@
// Types et données de test alignés sur l'API REST PolyMPR
// --- Types ---
export interface Student {
numEtud: number;
nom: string;
prenom: string;
idPromo: string;
}
export interface Promotion {
idPromo: string;
annee: string;
}
export interface Prof {
id: number;
nom: string;
prenom: string;
}
export interface Module {
id: string;
nom: string;
}
export interface Note {
note: number;
numEtud: number;
idModule: string;
}
export interface UE {
id: number;
nom: string;
}
export interface UeModule {
idModule: string;
idUE: number;
idPromo: string;
coeff: number;
}
export interface Enseignement {
idProf: number;
idModule: string;
idPromo: string;
}
export interface Ajustement {
numEtud: number;
idUE: number;
valeur: number;
}
export interface ImportResult {
imported: number;
errors: { line: number; message: string }[];
}
export interface ApiError {
error: string;
}
// --- Fixtures ---
export const students: Student[] = [
{ numEtud: 21212006, nom: "Dupont", prenom: "Jean", idPromo: "4AFISE25/26" },
{
numEtud: 21212007,
nom: "Martin",
prenom: "Alice",
idPromo: "4AFISE25/26",
},
{
numEtud: 21212008,
nom: "Durand",
prenom: "Claire",
idPromo: "3AFISE25/26",
},
];
export const promotions: Promotion[] = [
{ idPromo: "4AFISE25/26", annee: "2025" },
{ idPromo: "3AFISE25/26", annee: "2025" },
{ idPromo: "JIA4A2526", annee: "2025" },
];
export const profs: Prof[] = [
{ id: 1, nom: "Leclerc", prenom: "Jean" },
{ id: 2, nom: "Moreau", prenom: "Sophie" },
];
export const modules: Module[] = [
{ id: "JIN702C", nom: "Optimisation" },
{ id: "JIN703C", nom: "Informatique" },
{ id: "JIN704C", nom: "Physique" },
];
export const notes: Note[] = [
{ note: 15.5, numEtud: 21212006, idModule: "JIN702C" },
{ note: 12.0, numEtud: 21212006, idModule: "JIN703C" },
{ note: 18.0, numEtud: 21212007, idModule: "JIN702C" },
{ note: 9.0, numEtud: 21212008, idModule: "JIN704C" },
];
export const ues: UE[] = [
{ id: 1, nom: "UE Informatique" },
{ id: 2, nom: "UE Mathématiques" },
];
export const ueModules: UeModule[] = [
{ idModule: "JIN702C", idUE: 1, idPromo: "4AFISE25/26", coeff: 3.0 },
{ idModule: "JIN703C", idUE: 2, idPromo: "4AFISE25/26", coeff: 4.0 },
{ idModule: "JIN704C", idUE: 1, idPromo: "3AFISE25/26", coeff: 2.0 },
];
export const enseignements: Enseignement[] = [
{ idProf: 1, idModule: "JIN702C", idPromo: "4AFISE25/26" },
{ idProf: 2, idModule: "JIN703C", idPromo: "4AFISE25/26" },
{ idProf: 1, idModule: "JIN704C", idPromo: "3AFISE25/26" },
];
export const ajustements: Ajustement[] = [
{ numEtud: 21212006, idUE: 1, valeur: 13.25 },
{ numEtud: 21212008, idUE: 1, valeur: 11.0 },
];
// --- Réponses d'erreur standard ---
export const ERROR_NOT_FOUND: ApiError = { error: "Ressource introuvable" };
export const ERROR_CONFLICT: ApiError = { error: "Ressource déjà existante" };
export const ERROR_BAD_REQUEST: ApiError = { error: "Requête invalide" };
export const ERROR_UNAUTHORIZED: ApiError = { error: "Non authentifié" };
export const ERROR_FORBIDDEN: ApiError = { error: "Accès interdit" };
+55
View File
@@ -0,0 +1,55 @@
// Setup happy-dom + wrapper render pour les tests de composants Preact
import { Window } from "happy-dom";
let _window: Window | null = null;
/**
* Initialise un environnement DOM virtuel via happy-dom.
* À appeler avant de rendre des composants Preact dans les tests.
*/
export function setupDOM(): void {
_window = new Window({ url: "http://localhost" });
// Expose les globals DOM nécessaires à Preact
const globals = _window as unknown as Record<string, unknown>;
const target = globalThis as unknown as Record<string, unknown>;
for (
const key of [
"document",
"navigator",
"location",
"HTMLElement",
"HTMLInputElement",
"HTMLTextAreaElement",
"HTMLSelectElement",
"Event",
"CustomEvent",
"KeyboardEvent",
"MouseEvent",
"InputEvent",
"MutationObserver",
"requestAnimationFrame",
"cancelAnimationFrame",
]
) {
target[key] = globals[key];
}
target["window"] = _window;
}
/**
* Nettoie l'environnement DOM.
* À appeler dans un afterEach ou à la fin d'un test.
*/
export function cleanupDOM(): void {
if (_window) {
const doc = _window.document;
doc.body.innerHTML = "";
doc.head.innerHTML = "";
_window.close();
_window = null;
}
}
View File
+266
View File
@@ -0,0 +1,266 @@
import { assertEquals, assertExists } from "@std/assert";
import { getFetchCalls, mockFetch, restoreFetch } from "../helpers/api_mock.ts";
import { createMockDb } from "../helpers/db_mock.ts";
import {
ERROR_CONFLICT,
ERROR_NOT_FOUND,
modules,
notes,
type Student,
students,
} from "../helpers/fixtures.ts";
import { cleanupDOM, setupDOM } from "../helpers/render.ts";
// --- Fixtures ---
Deno.test("fixtures - students match API shape", () => {
assertEquals(students.length, 3);
assertEquals(students[0].numEtud, 21212006);
assertEquals(students[0].idPromo, "4AFISE25/26");
assertEquals(typeof students[0].idPromo, "string");
});
Deno.test("fixtures - modules have string ids", () => {
assertEquals(modules[0].id, "JIN702C");
assertEquals(typeof modules[0].id, "string");
});
Deno.test("fixtures - notes use decimal values", () => {
assertEquals(notes[0].note, 15.5);
assertEquals(notes[0].idModule, "JIN702C");
});
// --- Mock fetch simple (GET 200) ---
Deno.test("mockFetch - GET returns mocked data", async () => {
mockFetch({ "/students": students });
try {
const res = await fetch("http://localhost/api/students");
assertEquals(res.status, 200);
const data = await res.json();
assertEquals(data.length, 3);
assertEquals(data[0].nom, "Dupont");
} finally {
restoreFetch();
}
});
Deno.test("mockFetch - returns 404 for unknown routes", async () => {
mockFetch({});
try {
const res = await fetch("http://localhost/api/unknown");
assertEquals(res.status, 404);
} finally {
restoreFetch();
}
});
// --- Mock fetch avancé (méthodes + status codes) ---
Deno.test("mockFetch - POST 201 created", async () => {
const newStudent = students[0];
mockFetch({
"/students": { method: "POST", status: 201, body: newStudent },
});
try {
const res = await fetch("http://localhost/api/students", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newStudent),
});
assertEquals(res.status, 201);
const data = await res.json();
assertEquals(data.numEtud, 21212006);
} finally {
restoreFetch();
}
});
Deno.test("mockFetch - DELETE 204 no content", async () => {
mockFetch({
"/students/21212006": { method: "DELETE", status: 204 },
});
try {
const res = await fetch("http://localhost/api/students/21212006", {
method: "DELETE",
});
assertEquals(res.status, 204);
assertEquals(res.body, null);
} finally {
restoreFetch();
}
});
Deno.test("mockFetch - 404 error response", async () => {
mockFetch({
"/students/99999": { status: 404, body: ERROR_NOT_FOUND },
});
try {
const res = await fetch("http://localhost/api/students/99999");
assertEquals(res.status, 404);
const data = await res.json();
assertEquals(data.error, "Ressource introuvable");
} finally {
restoreFetch();
}
});
Deno.test("mockFetch - 409 conflict", async () => {
mockFetch({
"/enseignements": { method: "POST", status: 409, body: ERROR_CONFLICT },
});
try {
const res = await fetch("http://localhost/api/enseignements", {
method: "POST",
body: JSON.stringify({
idProf: 1,
idModule: "JIN702C",
idPromo: "4AFISE25/26",
}),
});
assertEquals(res.status, 409);
} finally {
restoreFetch();
}
});
// --- getFetchCalls ---
Deno.test("getFetchCalls - tracks all intercepted calls", async () => {
mockFetch({ "/notes": notes });
try {
await fetch("http://localhost/api/notes");
await fetch("http://localhost/api/notes?numEtud=21212006");
const calls = getFetchCalls();
assertEquals(calls.length, 2);
assertEquals(calls[0].method, "GET");
assertEquals(calls[1].url, "http://localhost/api/notes?numEtud=21212006");
} finally {
restoreFetch();
}
});
Deno.test("getFetchCalls - captures POST body", async () => {
mockFetch({ "/notes": { method: "POST", status: 201, body: notes[0] } });
try {
await fetch("http://localhost/api/notes", {
method: "POST",
body: JSON.stringify(notes[0]),
});
const calls = getFetchCalls();
assertEquals(calls.length, 1);
assertEquals(calls[0].method, "POST");
assertEquals((calls[0].body as { note: number }).note, 15.5);
} finally {
restoreFetch();
}
});
// --- Mock DB ---
Deno.test("mockDb - getTable returns seeded rows", () => {
const db = createMockDb({ tables: { students: [...students] } });
assertEquals(db.getTable("students").length, 3);
});
Deno.test("mockDb - findOne by key", () => {
const db = createMockDb({ tables: { students: [...students] } });
const found = db.findOne<Student>("students", (r) => r.numEtud === 21212006);
assertExists(found);
assertEquals(found.nom, "Dupont");
});
Deno.test("mockDb - findOne returns undefined for missing", () => {
const db = createMockDb({ tables: { students: [...students] } });
const found = db.findOne<Student>("students", (r) => r.numEtud === 99999);
assertEquals(found, undefined);
});
Deno.test("mockDb - insert adds a row", () => {
const db = createMockDb({ tables: { students: [] } });
const newStudent: Student = {
numEtud: 21212099,
nom: "Test",
prenom: "User",
idPromo: "4AFISE25/26",
};
db.insert("students", newStudent);
assertEquals(db.getTable("students").length, 1);
assertEquals(
db.findOne<Student>("students", (r) => r.numEtud === 21212099)?.nom,
"Test",
);
});
Deno.test("mockDb - updateWhere modifies matching rows", () => {
const db = createMockDb({ tables: { students: [...students] } });
const updated = db.updateWhere<Student>(
"students",
(r) => r.numEtud === 21212006,
{ prenom: "Marie" },
);
assertEquals(updated, 1);
assertEquals(
db.findOne<Student>("students", (r) => r.numEtud === 21212006)?.prenom,
"Marie",
);
});
Deno.test("mockDb - deleteWhere removes matching rows", () => {
const db = createMockDb({ tables: { students: [...students] } });
const deleted = db.deleteWhere<Student>(
"students",
(r) => r.numEtud === 21212006,
);
assertEquals(deleted, 1);
assertEquals(db.getTable("students").length, 2);
});
Deno.test("mockDb - findMany with filter", () => {
const db = createMockDb({ tables: { students: [...students] } });
const promo4 = db.findMany<Student>(
"students",
(r) => r.idPromo === "4AFISE25/26",
);
assertEquals(promo4.length, 2);
});
Deno.test("mockDb - reset clears all tables", () => {
const db = createMockDb({
tables: { students: [...students], notes: [...notes] },
});
db.reset();
assertEquals(db.getTable("students").length, 0);
assertEquals(db.getTable("notes").length, 0);
});
Deno.test("mockDb - isolated between instances", () => {
const db1 = createMockDb({ tables: { students: [...students] } });
const db2 = createMockDb({ tables: { students: [...students] } });
db1.deleteWhere<Student>("students", () => true);
assertEquals(db1.getTable("students").length, 0);
assertEquals(db2.getTable("students").length, 3);
});
// --- happy-dom ---
Deno.test({
name: "happy-dom - document is available after setup",
sanitizeResources: false,
sanitizeOps: false,
fn() {
setupDOM();
try {
const doc = globalThis.document;
assertExists(doc);
const div = doc.createElement("div");
div.textContent = "hello";
doc.body.appendChild(div);
assertEquals(doc.body.textContent, "hello");
} finally {
cleanupDOM();
}
},
});