Repository: numerique-gouv/ami-notifications-api
Pull request #22: Move `send notification` to a dedicated page/URL
Author: login: davidbgk
Created: 2025-05-12T17:59:44Z | Merged: 2025-05-13T15:28:25Z

Also, set a dropdown to choose from registered users.

<img width="1503" alt="Capture d’écran, le 2025-05-12 à 13 55 14" src="https://github.com/user-attachments/assets/2deff560-70fb-4fcb-a028-7d9921825b6f" />



Diff:
diff --git a/app/__init__.py b/app/__init__.py
index e04fcc5f..2aa398f6 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -5,10 +5,14 @@
 
 import httpx
 from litestar import Litestar, get, post
+from litestar.contrib.jinja import JinjaTemplateEngine
 from litestar.di import Provide
 from litestar.exceptions import NotFoundException
 from litestar.params import Body
+from litestar.response import Template
 from litestar.static_files import create_static_files_router
+from litestar.template.config import TemplateConfig
+from sqlalchemy import desc
 from sqlalchemy.exc import NoResultFound
 from sqlalchemy.types import JSON
 from sqlmodel import Column, Field, SQLModel, col, select
@@ -100,15 +104,21 @@ async def notify(
     return data
 
 
-async def get_user_list(db_session: AsyncSession) -> list[Registration]:
-    query = select(Registration)
+async def get_registration_list(db_session: AsyncSession) -> list[Registration]:
+    query = select(Registration).order_by(col(Registration.email).desc())
+    result = await db_session.exec(query)
+    return list(result.all())
+
+
+async def get_notification_list(db_session: AsyncSession) -> list[Notification]:
+    query = select(Notification).order_by(col(Notification.date).desc())
     result = await db_session.exec(query)
     return list(result.all())
 
 
 @get("/notification/users")
 async def list_users(db_session: AsyncSession) -> list[Registration]:
-    return await get_user_list(db_session)
+    return await get_registration_list(db_session)
 
 
 @get("/notifications/{email:str}")
@@ -118,6 +128,19 @@ async def get_notifications(db_session: AsyncSession, email: str) -> list[Notifi
     return list(result.all())
 
 
+#### VIEWS
+
+
+@get(path="/admin/", sync_to_thread=False)
+async def admin(db_session: AsyncSession) -> Template:
+    registrations = await get_registration_list(db_session)
+    notifications = await get_notification_list(db_session)
+    return Template(
+        template_name="admin.html",
+        context={"registrations": registrations, "notifications": notifications},
+    )
+
+
 #### APP
 
 
@@ -143,10 +166,12 @@ def create_app(database_connection=db_connection, webpush_init=provide_webpush)
             notify,
             list_users,
             get_notifications,
+            admin,
         ],
         dependencies={
             "db_session": Provide(provide_db_session),
             "webpush": Provide(webpush_init, use_cache=True, sync_to_thread=True),
         },
         lifespan=[database_connection],
+        template_config=TemplateConfig(directory=Path("templates"), engine=JinjaTemplateEngine),
     )
diff --git a/biome.json b/biome.json
new file mode 100644
index 00000000..897309f4
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,41 @@
+{
+  "formatter": {
+    "enabled": true,
+    "formatWithErrors": false,
+    "ignore": [],
+    "attributePosition": "auto",
+    "indentStyle": "space",
+    "indentWidth": 4,
+    "lineEnding": "lf",
+    "lineWidth": 88
+  },
+  "linter": {
+    "enabled": true,
+    "rules": {
+      "style": {
+        "useBlockStatements": "warn",
+        "noShoutyConstants": "warn"
+      }
+    }
+  },
+  "javascript": {
+    "formatter": {
+      "arrowParentheses":"always",
+      "bracketSameLine": false,
+      "bracketSpacing": true,
+      "jsxQuoteStyle": "double",
+      "quoteProperties": "asNeeded",
+      "quoteStyle": "single",
+      "semicolons": "asNeeded",
+      "trailingComma": "es5"
+    }
+  },
+  "json": {
+    "formatter": {
+      "trailingCommas": "none"
+    }
+  },
+  "organizeImports": {
+    "enabled": true
+  }
+}
diff --git a/public/admin.js b/public/admin.js
new file mode 100644
index 00000000..2e76f6a8
--- /dev/null
+++ b/public/admin.js
@@ -0,0 +1,26 @@
+const notifyMessage = async () => {
+    const payload = {
+        message: notifyMessageInput.value,
+        email: registerEmailInput.value,
+    }
+    console.log('notifying a message')
+    notifyMessageBtn.disabled = true
+    notifyMessageStatus.innerText = 'Notifying...'
+    const response = await fetch('/notification/send', {
+        method: 'POST',
+        body: JSON.stringify(payload),
+    })
+    console.log('response from AMI:', response)
+    notifyMessageBtn.disabled = false
+    if (response.status < 400) {
+        notifyMessageStatus.innerText = 'Done!'
+    } else {
+        notifyMessageStatus.innerText = `error ${response.status}: ${response.statusText}, ${response.body}`
+    }
+}
+
+const registerEmailInput = document.querySelector('#register-email')
+const notifyMessageInput = document.querySelector('#notify-message-input')
+const notifyMessageBtn = document.querySelector('#notify-message')
+notifyMessageBtn.addEventListener('click', notifyMessage)
+const notifyMessageStatus = document.querySelector('#notify-message-status')
diff --git a/public/index.html b/public/index.html
index 7c2df856..9363c7e8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -20,13 +20,6 @@
     <p><label>Email for the registration <input type="text" id="register-email" /></label></p>
     <p><button type="button" id="register-with-ami">Register with AMI</button> <span id="registration-status"></span></p>
   </form>
-
-  <hr />
-  <h1>RDV-SP</h1>
-  <form>
-    <p><label>Message to notify <input type="text" id="notify-message-input" /></label></p>
-    <p><button type="button" id="notify-message">Notify me</button> <span id="notify-message-status"></span></p>
-  </form>
 </main>
 <script type="text/javascript" src="index.js"></script>
 </body>
diff --git a/public/index.js b/public/index.js
index 694857bb..a6f9025a 100644
--- a/public/index.js
+++ b/public/index.js
@@ -98,30 +98,6 @@ const registerWithAMI = async () => {
   }
 };
 
-const notifyMessage = async () => {
-  const payload = {
-    message: notifyMessageInput.value,
-    email: registerEmailInput.value,
-  }
-  console.log("notifying a message");
-  notifyMessageBtn.disabled = true;
-  notifyMessageStatus.innerText = "Notifying...";
-  const response = await fetch(
-    "/notification/send",
-    {
-      method: "POST",
-      body: JSON.stringify(payload),
-    }
-  );
-  console.log("response from AMI:", response);
-  notifyMessageBtn.disabled = false;
-  if (response.status < 400) {
-    notifyMessageStatus.innerText = "Done!";
-  } else {
-    notifyMessageStatus.innerText = `error ${response.status}: ${response.statusText}, ${response.body}`;
-  }
-}
-
 const updateButtonsStates = async () => {
   const isGranted = await checkNotificationPermission();
   console.log("inside updateButtonsStates, isGranted:", isGranted);
@@ -148,10 +124,6 @@ const registerEmailInput = document.querySelector("#register-email");
 const registerBtn = document.querySelector("#register-with-ami");
 registerBtn.addEventListener("click", registerWithAMI);
 const registrationStatus = document.querySelector("#registration-status");
-const notifyMessageInput = document.querySelector("#notify-message-input");
-const notifyMessageBtn = document.querySelector("#notify-message");
-notifyMessageBtn.addEventListener("click", notifyMessage);
-const notifyMessageStatus = document.querySelector("#notify-message-status");
 
 updateButtonsStates();
 
diff --git a/templates/admin.html b/templates/admin.html
new file mode 100644
index 00000000..348811b5
--- /dev/null
+++ b/templates/admin.html
@@ -0,0 +1,32 @@
+{% extends "base.html" %}
+
+{% block container %}
+
+  <h2>Envoyer une notification</h2>
+
+  <form>
+    <p>
+      <label>User to notify
+        <select id="register-email">
+          {% for registration in registrations %}
+            <option value="{{ registration.email }}">{{ registration.email }}</option>
+          {% endfor %}
+        </select>
+      </label>
+    </p>
+    <p><label>Message to send <input type="text" id="notify-message-input" /></label></p>
+    <p><button type="button" id="notify-message">Send notification</button> <span id="notify-message-status"></span></p>
+  </form>
+
+  <h2>Historique global</h2>
+  <ul>
+    {% for notification in notifications %}
+      <li>{{ notification }}</li>
+    {% endfor %}
+  </ul>
+{% endblock container %}
+
+
+{% block extra_body %}
+  <script type="text/javascript" src="/admin.js"></script>
+{% endblock extra_body %}
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 00000000..4b084aff
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,15 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <title>Notification test</title>
+  <meta charset="UTF-8" />
+  <link rel="manifest" href="/manifest.json">
+  <link rel="stylesheet" href="/pico.min.css">
+</head>
+<body>
+<main class="container">
+  {% block container %}{% endblock container %}
+</main>
+{% block extra_body %}{% endblock extra_body %}
+</body>
+</html>


Reviews:
user: login: magopian
body: Nice! I love it, makes it way more ergonomic and usable for non-tech people than using the API itself.

Nice touch with biome, good tooling is good for morale ;)
Would we want a `package.json` file with the dependencies, and a script to run all the stuff?
And maybe an entry in the `Makefile` for consistency, and some addition to the README?

We might want to start a `CONTRIBUTING` file actually... I'll create a new issue: #23 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-13T09:11:03Z
user: login: davidbgk
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T13:18:33Z
user: login: davidbgk
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T15:27:26Z

Review comments:
diff_hunk: @@ -100,15 +104,21 @@ async def notify(
     return data
 
 
-async def get_user_list(db_session: AsyncSession) -> list[Registration]:
-    query = select(Registration)
+async def get_registration_list(db_session: AsyncSession) -> list[Registration]:
+    query = select(Registration).order_by(desc(Registration.email))
+    result = await db_session.exec(query)
+    return list(result.all())
+
+
+async def get_notification_list(db_session: AsyncSession) -> list[Notification]:
+    query = select(Notification).order_by(desc(Notification.date))
path: app/__init__.py
user: login: magopian
body: Sadly, this gives a type error:

But it can be fixed with the following change:
```suggestion
    query = select(Notification).order_by(col(Notification.date).desc())
```

It's a bit frustrating that we have several ways to do the same thing, and use different solutions throughout the code, not very pythonic :/
Eg:
- `query = select(Notification).order_by(desc(Notification.date))`
- `query = select(Notification).order_by(Notification.date.desc())`
- `query = select(Notification).order_by(col(Notification.date).desc())`

Maybe we should be using `col()` everywhere for consistency, as we're already using it for:
`query = select(Notification).where(col(Notification.email) == email)`
(actually, at least according to pyright, entirely removing the `col()` from this line is fine).
created_at: 2025-05-13T09:00:23Z
author_association: COLLABORATOR
diff_hunk: @@ -100,15 +104,21 @@ async def notify(
     return data
 
 
-async def get_user_list(db_session: AsyncSession) -> list[Registration]:
-    query = select(Registration)
+async def get_registration_list(db_session: AsyncSession) -> list[Registration]:
+    query = select(Registration).order_by(desc(Registration.email))
+    result = await db_session.exec(query)
+    return list(result.all())
+
+
+async def get_notification_list(db_session: AsyncSession) -> list[Notification]:
+    query = select(Notification).order_by(desc(Notification.date))
     result = await db_session.exec(query)
     return list(result.all())
 
 
 @get("/notification/users")
 async def list_users(db_session: AsyncSession) -> list[Registration]:
-    return await get_user_list(db_session)
+    return await get_registration_list(db_session)
path: app/__init__.py
user: login: magopian
body: Nice, way better naming, thanks 🙏 
created_at: 2025-05-13T09:00:46Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,41 @@
+{
+  "formatter": {
+    "enabled": true,
+    "formatWithErrors": false,
+    "ignore": [],
+    "attributePosition": "auto",
+    "indentStyle": "space",
+    "indentWidth": 4,
+    "lineEnding": "lf",
+    "lineWidth": 88
+  },
+  "linter": {
+    "enabled": true,
+    "rules": {
+      "style": {
+        "useBlockStatements": "warn",
+        "noShoutyConstants": "warn"
+      }
+    }
+  },
+  "javascript": {
+    "formatter": {
+      "arrowParentheses":"always",
+      "bracketSameLine": false,
+      "bracketSpacing": true,
+      "jsxQuoteStyle": "double",
+      "quoteProperties": "asNeeded",
+      "quoteStyle": "single",
+      "semicolons": "asNeeded",
+      "trailingComma": "es5"
+    }
+  },
+  "json": {
+    "formatter": {
+      "trailingCommas": "none"
+    }
+  },
+  "organizeImports": {
+    "enabled": true
+  }
+}
path: biome.json
user: login: magopian
body: oooof, that's some configuration, is there no "default, widely accepted as the standard" config, like for prettier/black/ruff...?

I guess i'm miss having a non-configurable "elm-format" like tool around.
Anyway, thanks for telling me about biome, looks nice!
created_at: 2025-05-13T09:02:19Z
author_association: COLLABORATOR
diff_hunk: @@ -100,15 +104,21 @@ async def notify(
     return data
 
 
-async def get_user_list(db_session: AsyncSession) -> list[Registration]:
-    query = select(Registration)
+async def get_registration_list(db_session: AsyncSession) -> list[Registration]:
+    query = select(Registration).order_by(desc(Registration.email))
+    result = await db_session.exec(query)
+    return list(result.all())
+
+
+async def get_notification_list(db_session: AsyncSession) -> list[Notification]:
+    query = select(Notification).order_by(desc(Notification.date))
path: app/__init__.py
user: login: davidbgk
body: I agree that it's not ideal, adding `col()` everywhere for consistency adds an overhead but I can live with it because it's also more explicit than the magic SQLAlchemy is doing here. I'll uniformise that 👍 
created_at: 2025-05-13T13:18:33Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,41 @@
+{
+  "formatter": {
+    "enabled": true,
+    "formatWithErrors": false,
+    "ignore": [],
+    "attributePosition": "auto",
+    "indentStyle": "space",
+    "indentWidth": 4,
+    "lineEnding": "lf",
+    "lineWidth": 88
+  },
+  "linter": {
+    "enabled": true,
+    "rules": {
+      "style": {
+        "useBlockStatements": "warn",
+        "noShoutyConstants": "warn"
+      }
+    }
+  },
+  "javascript": {
+    "formatter": {
+      "arrowParentheses":"always",
+      "bracketSameLine": false,
+      "bracketSpacing": true,
+      "jsxQuoteStyle": "double",
+      "quoteProperties": "asNeeded",
+      "quoteStyle": "single",
+      "semicolons": "asNeeded",
+      "trailingComma": "es5"
+    }
+  },
+  "json": {
+    "formatter": {
+      "trailingCommas": "none"
+    }
+  },
+  "organizeImports": {
+    "enabled": true
+  }
+}
path: biome.json
user: login: davidbgk
body: For the record, after discussion, that configuration is the new "default, widely accepted as the standard" :p
created_at: 2025-05-13T15:27:26Z
author_association: COLLABORATOR

PR comments:
user: login: davidbgk
created_at: 2025-05-12T18:01:58Z
body: I tried to keep it as light as possible for now for a reduced diff. It's mostly moving code.
author_association: COLLABORATOR
user: login: magopian
created_at: 2025-05-13T06:37:34Z
body: I believe this at least partially fixes #9 
author_association: COLLABORATOR