Compare commits

..

59 Commits

Author SHA1 Message Date
Jacques ROUSSEL bb85dc77cf 🔐(secrets) bump secrets
Bump secret in order to enable IA feature on staging
2024-11-29 18:12:45 +01:00
lebaudantoine cb5bfa3d29 wip enhance name and docstring 2024-11-29 17:51:44 +01:00
lebaudantoine 2e50ae5bba wip build summary image 2024-11-29 17:51:44 +01:00
lebaudantoine dd58cd941e wip prepare staging 2024-11-29 17:51:44 +01:00
lebaudantoine 2ee5caa0dc wip parametrize model name 2024-11-29 17:51:44 +01:00
lebaudantoine ad99db4706 wip write docstring 2024-11-29 17:51:44 +01:00
lebaudantoine f99312f12b wip add a status endpoint 2024-11-29 17:51:44 +01:00
lebaudantoine 5c84dbff88 wip remove useless endpoint 2024-11-29 17:51:44 +01:00
lebaudantoine 17d213fb49 wip allow parametrizing the openai base url 2024-11-29 17:51:44 +01:00
lebaudantoine 14ec684f7f wip kubernitize the micro service 2024-11-29 17:51:43 +01:00
lebaudantoine ea1438614d wip to drop 2024-11-29 17:51:43 +01:00
lebaudantoine e0a15e6f29 wip add a bearer token on the current push endpoint 2024-11-29 17:51:43 +01:00
lebaudantoine 2cfde5a642 wip post response to webhook 2024-11-29 17:51:43 +01:00
lebaudantoine 3252636053 wip use proper logging 2024-11-22 16:50:29 +01:00
lebaudantoine 2d574885d0 wip introduce open ai call 2024-11-22 16:49:06 +01:00
lebaudantoine 533861c93a wip add minio interconnexion 2024-11-22 16:47:34 +01:00
lebaudantoine 81d06a097b wip setup redis and celery 2024-11-22 16:46:13 +01:00
lebaudantoine 0de83c7ce3 wip create a compose 2024-11-22 16:38:59 +01:00
lebaudantoine 20acfad12d wip add a docker file 2024-11-22 16:38:59 +01:00
lebaudantoine 8dfc2ace51 wip init app 2024-11-22 16:38:57 +01:00
lebaudantoine c4206b4b24 🔖(patch) bump release to 0.1.8 2024-11-19 00:24:29 +01:00
lebaudantoine 732df0768c 💄(frontend) adjust home based on Sam feedbacks
Based on Samuel's feedbacks, adjust few details.
2024-11-18 13:31:26 +01:00
lebaudantoine 8a96b6939a 🐛(helm) rename bucket
Wrong copy paste. My bad, bucket's name wasn't matching our project.
2024-11-18 10:04:21 +01:00
lebaudantoine e78b8c69fe 🩹(frontend) add missing key
Fix missing warning, key props was missing on the item of the
for loop.
2024-11-18 01:22:54 +01:00
lebaudantoine 6e49ab3230 💄(frontend) narrow home layout
Avoid having too much white space around the home content.
2024-11-18 01:22:54 +01:00
lebaudantoine 200e2d3c2f 💩(frontend) introduce multistep form
Add an open feedback form step, to allow people giving us some
feedbacks on what's wrong with our product.
2024-11-18 01:22:54 +01:00
lebaudantoine 4ffef3f94a 🚸(frontend) add hover animation on rating Button
Nitpicking, enhance small interactivity with form.
2024-11-18 01:22:54 +01:00
lebaudantoine 8e62f2ecd3 🩹(frontend) fix TextArea export
Wrongly exported the TextArea primitive, mybad.
Fixed !
2024-11-18 01:22:54 +01:00
lebaudantoine d024fb1b95 (frontend) capture from with PostHog survey
Initialize logic related to PostHog, use an API survey to avoid
having element branded with PostHog logo.
2024-11-18 01:22:54 +01:00
lebaudantoine 7e49f0f661 ⬆️(frontend) update PostHog sdk
Required by PostHog app, to benefits from latest survey features.
2024-11-18 01:22:54 +01:00
lebaudantoine fb8c0fd1b5 💄(frontend) refactor feedbacks screen
Enhance initial screen. Allow user returning to the meeting.
Also, add basic and non-functional rating component to mock,
what the form would be.
2024-11-18 01:22:54 +01:00
lebaudantoine b07e4c58b4 💄(frontend) remove slider feature on small screens
Fit the slider content into small screen, by disabling all features
related to the slider. Good enough for a first draft.
2024-11-17 16:48:54 +01:00
lebaudantoine e6bbf2d3c8 💄(frontend) enhance header responsiveness
Quick fix, not optimal. Removed a useless breaking point IMO.
I won't spend too much time on the Header component, I'll
introduce new features needing a heavy layout update.
2024-11-17 16:48:54 +01:00
lebaudantoine 28899a2aaf 🚨(frontend) fix bundler warning on build
Typo while styling the homepage. Fixed it.
Minor issue.
2024-11-17 16:48:54 +01:00
lebaudantoine a5bc66a921 💄(frontend) adjust title's responsiveness
Needed for small and phone screen.
Not a huge fan of having breaking point to manage, but
made an exception for the landing page content.
2024-11-17 16:48:54 +01:00
lebaudantoine adb09410da 💄(frontend) introduce a new breaking point
Needed a trade off between xs and sm breaking points.
Introduce xsm, I am not very satisfied with this naming.
We might refactor xs in xss. Not urgent.
2024-11-17 16:48:54 +01:00
lebaudantoine 061f12e7e2 💄(frontend) update feedback button to banner with context
Refactored the feedback alert button into a more discreet and polished banner.
The banner provides additional context about Visio being under construction.
Also updated the link to the feedback form.
2024-11-17 15:29:26 +01:00
lebaudantoine 24e819a533 🩹(frontend) use browser language if no localStorage for detection
Previously, language detection was failing to read browser settings correctly.
Added explicit detection order to ensure localStorage preferences are checked
before falling back to browser language.
2024-11-17 14:56:32 +01:00
lebaudantoine 5c48ac100a (frontend) allow user register to beta feature
I've created a beta form, allowing interested visitors to
register to be a beta user.

This form is yet created with Grist, could be enhance. I'll
investigate using PostHog.
2024-11-17 01:08:13 +01:00
lebaudantoine 4434f0265d 💫(frontend) add fadein animation on home
Elements' apparition felt quite harsh.
Add an animation to fade in content.
Used a pre-defined animation in panda configs.
2024-11-17 01:08:13 +01:00
lebaudantoine 0522696842 💫(frontend) add animation on interaction with slider
Add a minimal animation to enhance micro-interaction with our app.
I've tried being minimal, please feel free to enhance or tune this
animation.
2024-11-17 01:08:13 +01:00
lebaudantoine 8516782d79 (frontend) add slide feature in the IntroSlider
Avoided installing a dependency for such small piece of code.
I've implemented a naive component, which allow users explore
the slide presenting key feature of our app.

User experience should be ok. However, I might need to optimize
image format and loading strategy. First 'raw' iteration, gonna
optimize it in the future.
2024-11-17 01:08:13 +01:00
lebaudantoine b06880be15 💄(frontend) enhance home page
I've totally rework the homepage, heavily inspired by GMeet.
Goal: make it more pro, and polished.
2024-11-17 01:08:13 +01:00
lebaudantoine 5de9cec688 (frontend) introduce learn more link component
Component providing a 'learn more' feature. Inspired by GMeet.
Naive component.
2024-11-17 01:08:13 +01:00
lebaudantoine 7dfd86873c 🚸(frontend) update A component hover animation
I found original to hide text decoration on hover. It felt wrong
to me, I proposed a new animation. Please feel free to rework it
or enhance it in the future.
2024-11-17 01:08:13 +01:00
lebaudantoine 7cee409d22 🚧(frontend) add intro slider component with feature slides
Created a component for an intro slider to showcase app features.
Each slide will include a minimal illustration and concise descriptions.

Work In Progress, Slide logic will be added in upcomming commits.
2024-11-17 01:08:13 +01:00
lebaudantoine 7ddc448c88 💄(frontend) remove bottom border on header
The bottom border on the header was unnecessary
and removed for a cleaner design.
2024-11-17 01:08:13 +01:00
lebaudantoine 6fcb69bd3c 🩹(frontend) fix horizontal scroll issue
Oooopsie while merging chat, I forgot to test horizontal scrolling.
It ended up creating a scroll issue. Fixed!

This fix might create a new issue for mobile users, I'll refactor
their layout ASAP.
2024-11-16 00:29:58 +01:00
lebaudantoine 6aed4cb751 🚸(frontend) auto-populate prejoin screen with user's default full name
Streamline user joining a room by pre-fillig name field from
their ProConnect data. Reduces friction and typos in the join flow.

This feature will be mostly used by new users. Recurrent users have
their previous choices persisted in local storage.
2024-11-16 00:29:58 +01:00
lebaudantoine f433f59d3f ♻️(frontend) prioritize full name display before email
Why? user friendly, and more human-readable identifier. Should
be enhance later on, to display both info together.
2024-11-16 00:29:58 +01:00
lebaudantoine fba9910705 (frontend) declare full and short name in APIUser type
API now serialize these two data. Save them when requestion /me.
2024-11-16 00:29:58 +01:00
lebaudantoine dcba3330f7 🛂(backend) request given and usual name scopes from ProConnect
Request the necessary scopes from ProConnect service.
Update configurations in every environments.

Note: ask given_name and usual_name scopes to get users' info.

(these scopes should be granted by default by ProConnect when
requesting a client id client secret)
2024-11-16 00:29:58 +01:00
lebaudantoine 82bb5f0f8b (backend) persist OIDC first name and last name while authenticating
Inspired by @sampaccoud's eee2003 commit on impress, adapt the code to be more
Pythonic. Add basic test coverage for user name synchronization on login. User
name fields now update automatically at each login when new data is available.

Note: current logic doesn't handle the case where a user with existing names
logs in with missing first/last names - should we clear the names then?

Removing a field that was present in the initial form is not a valid update
operation.
2024-11-15 23:38:31 +01:00
lebaudantoine 0fd06ef6c0 ♻️(backend) isolate authentication tests when dealing only with email
Refactor a test to narrow down its scope to email-related updates.
2024-11-15 23:38:31 +01:00
lebaudantoine bd4dec6f27 (backend) serialize user name-related fields
Needed in the frontend. Updated existing tests accordingly.
Names are not yet saved while logging the user, it will be
added in the upcomming commits.
2024-11-15 23:38:31 +01:00
lebaudantoine a987830fb3 🗑️(backend) remove useless methods on User
While removing analytics code in 15e922f, I forgot to remove
related code in the User model, used nowwhere else. Oopsie.
Fixed it!
2024-11-15 23:38:31 +01:00
lebaudantoine 7f09636791 (backend) add full_name short_name on User model
Following @sampaccoud's work on impress, add new fields to handle
user names in our application.

@sampaccoud preferred having a full and short names instead of
a basic first and last ones, to follow common good practices, and
avoid having frontend formating names (from my understanding).

Please see commit eee20033 on Impress.
2024-11-15 23:38:31 +01:00
lebaudantoine 3460ec8808 ✏️(backend) fix minor typo
login is a noun, the verb needs a whitespace.
2024-11-15 23:38:31 +01:00
lebaudantoine 86cda75b39 🐛(frontend) tune connection parameters for low bandwidth clients
DINUM users face connection issues in bandwidth-constrained environments.
They cannot reach their room, the peer connection timeouts while negotiating
TURN/TLS handshake (from our current understanding).

I am not sure this issue is linked to those parameters, at least try something.
2024-11-15 23:38:31 +01:00
72 changed files with 2525 additions and 244 deletions
+53
View File
@@ -117,10 +117,63 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-summary:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-summary
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target production -f src/summary/Dockerfile'
docker-image-name: 'docker.io/lasuite/meet-summary:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/summary/Dockerfile
target: production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
needs:
- build-and-push-frontend
- build-and-push-backend
- build-and-push-summary
runs-on: ubuntu-latest
if: |
github.event_name != 'pull_request'
+3
View File
@@ -79,3 +79,6 @@ db.sqlite3
# Egress output
docker/livekit/out
# Summary
src/vsummary
+11
View File
@@ -28,6 +28,17 @@ docker_build(
]
)
docker_build(
'localhost:5001/meet-summary:latest',
context='../src/summary',
dockerfile='../src/summary/Dockerfile',
only=['.', '../../docker', '../../.dockerignore'],
target = 'production',
live_update=[
sync('../src/summary', '/home/summary'),
]
)
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e dev template .'))
migration = '''
+1 -1
Submodule secrets updated: 8ef9f4513a...142e7a70b1
+33 -4
View File
@@ -22,7 +22,19 @@ class UserAdmin(auth_admin.UserAdmin):
)
},
),
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
(
_("Personal info"),
{
"fields": (
"sub",
"email",
"full_name",
"short_name",
"language",
"timezone",
)
},
),
(
_("Permissions"),
{
@@ -52,6 +64,8 @@ class UserAdmin(auth_admin.UserAdmin):
"sub",
"admin_email",
"email",
"full_name",
"short_name",
"is_active",
"is_staff",
"is_superuser",
@@ -60,9 +74,24 @@ class UserAdmin(auth_admin.UserAdmin):
"updated_at",
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
search_fields = ("id", "sub", "admin_email", "email")
ordering = (
"is_active",
"-is_superuser",
"-is_staff",
"-is_device",
"-updated_at",
"full_name",
)
readonly_fields = (
"id",
"sub",
"email",
"full_name",
"short_name",
"created_at",
"updated_at",
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")
class ResourceAccessInline(admin.TabularInline):
+2 -2
View File
@@ -14,8 +14,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email"]
read_only_fields = ["id", "email"]
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
class ResourceAccessSerializerMixin:
+37 -1
View File
@@ -75,11 +75,16 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
email = user_info.get("email")
user = self.get_existing_user(sub, email)
claims = {
"email": email,
"full_name": self.compute_full_name(user_info),
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
}
if not user and self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(
sub=sub,
email=email,
password="!", # noqa: S106
**claims,
)
elif not user:
return None
@@ -87,6 +92,8 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
return user
def get_existing_user(self, sub, email):
@@ -104,3 +111,32 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
_("Multiple user accounts share a common email.")
) from e
return None
@staticmethod
def compute_full_name(user_info):
"""Compute user's full name based on OIDC fields in settings."""
full_name = " ".join(
filter(
None,
(
user_info.get(field)
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
),
)
)
return full_name or None
@staticmethod
def update_user_if_needed(user, claims):
"""Update user claims if they have changed."""
user_fields = vars(user.__class__) # Get available model fields
updated_claims = {
key: value
for key, value in claims.items()
if value and key in user_fields and value != getattr(user, key)
}
if not updated_claims:
return
User.objects.filter(sub=user.sub).update(**updated_claims)
+2
View File
@@ -23,6 +23,8 @@ class UserFactory(factory.django.DjangoModelFactory):
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
full_name = factory.Faker("name")
short_name = factory.Faker("first_name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
@@ -0,0 +1,23 @@
# Generated by Django 5.1.1 on 2024-11-13 09:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_recording_mode'),
]
operations = [
migrations.AddField(
model_name='user',
name='full_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
),
migrations.AddField(
model_name='user',
name='short_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='short name'),
)
]
+5 -9
View File
@@ -141,11 +141,14 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
# stores the email used by staff users to login to the admin site
# stores the email used by staff users to log in to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(
_("short name"), max_length=100, null=True, blank=True
)
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
@@ -204,13 +207,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""
return []
@property
def email_anonymized(self):
"""Anonymize the email address by replacing the local part with asterisks."""
if not self.email:
return ""
return f"***@{self.email.split('@')[1]}"
def get_resource_roles(resource: models.Model, user: User) -> List[str]:
"""
@@ -62,15 +62,14 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
def test_authentication_getter_new_user_with_email(monkeypatch):
"""
If no user matches, a user should be created.
User's email and name should be set on the identity.
The "email" field on the User model should not be set as it is reserved for staff users.
User's info contains an email, created user's email should be filled.
"""
klass = OIDCAuthenticationBackend()
email = "meet@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
@@ -80,6 +79,34 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name is None
assert user.short_name is None
assert user.password == "!"
assert models.User.objects.count() == 1
@pytest.mark.parametrize("email", [None, "johndoe@foo.com"])
def test_authentication_getter_new_user_with_names(monkeypatch, email):
"""
If no user matches, a user should be created.
User's info contains name-related field, created user's full and short names should be filled,
whether the email is filled
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "123", "given_name": "John", "usual_name": "Doe", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -285,3 +312,103 @@ def test_authentication_getter_existing_user_email_tricky(email, monkeypatch, se
)
assert user != db_user
@pytest.mark.parametrize(
"given_name, usual_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields(
given_name, usual_name, email, django_assert_num_queries, monkeypatch
):
"""It should update the email or name fields on the user when they change."""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": email,
"given_name": given_name,
"usual_name": usual_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{given_name:s} {usual_name:s}"
assert user.short_name == given_name
@pytest.mark.parametrize(
"user_info, expected_name",
[
({"given_name": "John", "family_name": "Doe"}, "John Doe"),
(
{"given_name": "John", "middle_name": "M", "family_name": "Doe"},
"John M Doe",
),
({"family_name": "Doe"}, "Doe"),
({"given_name": "", "family_name": ""}, None),
({}, None),
],
)
def test_compute_full_name(user_info, expected_name, settings):
"""Test full name computation from OIDC user info fields."""
settings.OIDC_USERINFO_FULLNAME_FIELDS = [
"given_name",
"middle_name",
"family_name",
]
klass = OIDCAuthenticationBackend()
assert klass.compute_full_name(user_info) == expected_name
def test_compute_full_name_no_fields(settings):
"""Test full name computation with empty field configuration."""
settings.OIDC_USERINFO_FULLNAME_FIELDS = []
klass = OIDCAuthenticationBackend()
assert klass.compute_full_name({"given_name": "John"}) is None
@pytest.mark.parametrize(
"claims",
[
{"email": "john.doe@example.com"}, # Same data - no change needed
{"email": ""}, # Empty strings should not override
{"non_related_field": "foo"}, # Unrelated fields should be ignored
{}, # Empty claims should not affect user
{"email": None}, # None values should be ignored
],
)
def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
"""Test that user attributes remain unchanged when claims don't require updates."""
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
klass = OIDCAuthenticationBackend()
with django_assert_num_queries(0):
klass.update_user_if_needed(user, claims)
user.refresh_from_db()
assert user.email == "john.doe@example.com"
@@ -301,6 +301,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"user": {
"id": str(user_access.user.id),
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
},
"resource": str(room.id),
"role": user_access.role,
@@ -310,6 +312,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"user": {
"id": str(other_user_access.user.id),
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -374,6 +378,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"user": {
"id": str(other_user_access.user.id),
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -383,6 +389,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"user": {
"id": str(user_access.user.id),
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
},
"resource": str(room.id),
"role": user_access.role,
+2
View File
@@ -96,6 +96,8 @@ def test_api_users_retrieve_me_authenticated():
assert response.json() == {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
}
@@ -44,12 +44,3 @@ def test_models_users_send_mail_main_missing():
user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address."
def test_models_users_email_anonymized():
"""The user's email should be anonymized if it exists."""
user = factories.UserFactory(email="john.doe@world.com")
assert user.email_anonymized == "***@world.com"
user = factories.UserFactory(email=None)
assert user.email_anonymized == ""
+10
View File
@@ -390,6 +390,16 @@ class Base(Configuration):
OIDC_REDIRECT_FIELD_NAME = values.Value(
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
)
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
default=["given_name", "usual_name"],
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
environ_prefix=None,
)
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
default="given_name",
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None,
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.7"
version = "0.1.8"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+21 -7
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.7",
"version": "0.1.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.7",
"version": "0.1.8",
"dependencies": {
"@livekit/components-react": "2.6.5",
"@livekit/components-styles": "1.1.3",
@@ -22,7 +22,7 @@
"i18next-parser": "9.0.2",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.5.7",
"posthog-js": "1.167.0",
"posthog-js": "1.186.1",
"react": "18.3.1",
"react-aria-components": "1.4.0",
"react-dom": "18.3.1",
@@ -409,6 +409,7 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"extraneous": true,
"inBundle": true,
"license": "MIT",
"engines": {
@@ -4529,6 +4530,17 @@
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"node_modules/core-js": {
"version": "3.39.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -8253,13 +8265,15 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.167.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.167.0.tgz",
"integrity": "sha512-/zXQ6tuJgiF1d4mgg3UsAi/uoyg7UnfFNQtikuALmaE53xFExpcAKbMfHPG/f54QgTvLxSHyGL1kFl/1uspkGg==",
"version": "1.186.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.186.1.tgz",
"integrity": "sha512-m6TNW01nfqErwMxaZxNScYdMaUJO0s3bbmt/tboL29yZDnuHdOiYFbG+T4MCxdFxjWRa5gOR25bQD/SSt1t/4A==",
"license": "MIT",
"dependencies": {
"core-js": "^3.38.1",
"fflate": "^0.4.8",
"preact": "^10.19.3",
"web-vitals": "^4.0.1"
"web-vitals": "^4.2.0"
}
},
"node_modules/preact": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.7",
"version": "0.1.8",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -27,7 +27,7 @@
"i18next-parser": "9.0.2",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.5.7",
"posthog-js": "1.167.0",
"posthog-js": "1.186.1",
"react": "18.3.1",
"react-aria-components": "1.4.0",
"react-dom": "18.3.1",
+1
View File
@@ -40,6 +40,7 @@ const config: Config = {
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
breakpoints: {
xs: '22.6em', // 360px (we assume less than that are old/entry level mobile phones)
xsm: '31.25em', // 500px,
sm: '40em', // 640px
md: '48em', // 768px
lg: '64em', // 1024px
Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

-25
View File
@@ -1,25 +0,0 @@
import { css } from '@/styled-system/css'
import { RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { LinkButton } from '@/primitives'
export const Feedback = () => {
const { t } = useTranslation()
return (
<LinkButton
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
variant="success"
target="_blank"
>
<span className={css({ marginRight: 0.5 })} aria-hidden="true">
💡
</span>
{t('feedbackAlert')}
<RiExternalLinkLine
size={16}
className={css({ marginLeft: 0.5 })}
aria-hidden="true"
/>
</LinkButton>
)
}
@@ -0,0 +1,46 @@
import { css } from '@/styled-system/css'
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Text, A } from '@/primitives'
const GRIST_FORM =
'https://grist.numerique.gouv.fr/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4'
export const FeedbackBanner = () => {
const { t } = useTranslation()
return (
<div
className={css({
width: '100%',
backgroundColor: '#E8EDFF',
color: '#0063CB',
display: { base: 'none', sm: 'flex' },
justifyContent: 'center',
padding: '0.5rem 0',
})}
>
<div
className={css({
display: 'inline-flex',
gap: '0.5rem',
alignItems: 'center',
})}
>
<RiErrorWarningLine size={20} />
<Text as="p">{t('feedback.context')}</Text>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: 0.25,
})}
>
<A href={GRIST_FORM} target="_blank">
{t('feedback.cta')}
</A>
<RiExternalLinkLine size={16} aria-hidden="true" />
</div>
</div>
</div>
)
}
@@ -1,4 +1,6 @@
export type ApiUser = {
id: string
email: string
full_name: string
last_name: string
}
@@ -0,0 +1,248 @@
import firstSlide from '@/assets/intro-slider/1_solo.png'
import secondSlide from '@/assets/intro-slider/2_multiple.png'
import thirdSlide from '@/assets/intro-slider/3_resume.png'
import { styled } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Button, LinkButton } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
// todo - extract in a proper env variable
const BETA_USERS_FORM_URL =
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'
const Heading = styled('h2', {
base: {
width: 'fit-content',
marginBottom: 0,
fontSize: '1.5rem',
marginTop: '0.75rem',
lineHeight: '2rem',
maxWidth: '23rem',
textAlign: 'center',
textWrap: 'pretty',
},
})
const Body = styled('p', {
base: {
maxWidth: '23rem',
textAlign: 'center',
textWrap: 'pretty',
lineHeight: '1.2rem',
fontSize: '1rem',
},
})
const Image = styled('img', {
base: {
maxHeight: '362px',
height: '100%',
width: 'fit-content',
},
})
const Dot = styled('div', {
base: {
borderRadius: '50%',
display: 'inline-block',
height: '.375rem',
margin: '0 .25rem',
width: '.375rem',
},
variants: {
selected: {
true: {
backgroundColor: '#000091',
},
false: {
backgroundColor: '#CACAFB',
},
},
},
})
const Container = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
width: '100%',
justifyContent: 'space-between',
textAlign: 'center',
},
})
const ButtonContainer = styled('div', {
base: {
display: { base: 'none', xsm: 'block' },
},
})
const ButtonVerticalCenter = styled('div', {
base: {
marginTop: '13.3125rem',
transform: 'translateY(-50%)',
},
})
const SlideContainer = styled('div', {
base: {
alignItems: 'stretch',
display: 'flex',
position: 'relative',
},
})
const Slide = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.5rem',
justifyContent: 'start',
minHeight: { base: 'none', xsm: '550px' },
minWidth: { base: 'none', xsm: '200px' },
width: { base: '100%', xsm: '22.625rem' },
},
variants: {
visible: {
true: {
visibility: 'visible',
position: 'static',
},
false: {
visibility: 'hidden',
position: 'absolute',
},
},
},
defaultVariants: {
visible: false,
},
})
const TextAnimation = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
gap: '0.5rem',
},
variants: {
visible: {
true: {
opacity: 1,
transform: 'none',
transition: 'opacity ease-in .3s, transform ease-in .3s',
},
false: {
opacity: 0,
transform: 'translateX(-30%)',
},
},
},
})
type Slide = {
key: string
img: string
isAvailableInBeta?: boolean
}
// todo - optimize how images are imported
const SLIDES: Slide[] = [
{
key: 'slide1',
img: firstSlide,
},
{
key: 'slide2',
img: secondSlide,
},
{
key: 'slide3',
img: thirdSlide,
isAvailableInBeta: true,
},
]
export const IntroSlider = () => {
const [slideIndex, setSlideIndex] = useState(0)
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
const NUMBER_SLIDES = SLIDES.length
return (
<Container>
<div
className={css({
display: 'flex',
flexGrow: 1,
justifyContent: 'center',
})}
>
<ButtonContainer>
<ButtonVerticalCenter>
<Button
square
invisible
aria-label={t('previous.label')}
tooltip={t('previous.tooltip')}
onPress={() => setSlideIndex(slideIndex - 1)}
isDisabled={slideIndex == 0}
>
<RiArrowLeftSLine />
</Button>
</ButtonVerticalCenter>
</ButtonContainer>
<SlideContainer>
{SLIDES.map((slide, index) => (
<Slide visible={index == slideIndex} key={index}>
<Image src={slide.img} alt={t(`${slide.key}.imgAlt`)} />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
<Body>{t(`${slide.key}.body`)}</Body>
{slide.isAvailableInBeta && (
<LinkButton
href={BETA_USERS_FORM_URL}
tooltip={t('beta.tooltip')}
variant={'primary'}
size={'sm'}
style={{ marginTop: '1rem', width: 'fit-content' }}
>
{t('beta.text')}
</LinkButton>
)}
</TextAnimation>
</Slide>
))}
</SlideContainer>
<ButtonContainer>
<ButtonVerticalCenter>
<Button
square
invisible
aria-label={t('next.label')}
tooltip={t('next.tooltip')}
onPress={() => setSlideIndex(slideIndex + 1)}
isDisabled={slideIndex == NUMBER_SLIDES - 1}
>
<RiArrowRightSLine />
</Button>
</ButtonVerticalCenter>
</ButtonContainer>
</div>
<div
className={css({
marginTop: '0.5rem',
display: { base: 'none', xsm: 'block' },
})}
>
{SLIDES.map((_, index) => (
<Dot key={index} selected={index == slideIndex} />
))}
</div>
</Container>
)
}
@@ -0,0 +1,23 @@
import { A, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
const MANIFEST_LINK =
'https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/'
export const MoreLink = () => {
const { t } = useTranslation('home')
return (
<Text as={'p'} variant={'sm'} style={{ padding: '1rem 0' }}>
<A
href={MANIFEST_LINK}
target="_blank"
rel="noopener noreferrer"
aria-label={t('moreLinkLabel')}
>
{t('moreLink')}
</A>{' '}
{t('moreAbout')}
</Text>
)
}
+211 -69
View File
@@ -1,10 +1,9 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
import { Button, Menu, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { Button, Menu } from '@/primitives'
import { styled } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
import { Screen } from '@/layout/Screen'
import { Centered } from '@/layout/Centered'
import { generateRoomId } from '@/features/rooms'
import { useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
@@ -14,7 +13,132 @@ import { usePersistentUserChoices } from '@livekit/components-react'
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { useState } from 'react'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useState } from 'react'
import { css } from '@/styled-system/css'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
<div
className={css({
alignItems: 'center',
margin: 'auto',
display: 'inline-flex',
flexDirection: 'column',
height: '100%',
minHeight: '100%',
justifyContent: 'normal',
padding: '0 1rem',
width: 'calc(100% - 2rem)',
opacity: 0,
animation: '.5s ease-in fade 0s forwards',
lg: {
flexDirection: 'row',
justifyContent: 'center',
width: '100%',
padding: 0,
},
})}
>
{children}
</div>
)
}
const LeftColumn = ({ children }: { children?: ReactNode }) => {
return (
<div
className={css({
alignItems: 'center',
textAlign: 'center',
display: 'inline-flex',
flexDirection: 'column',
flexBasis: 'auto',
flexShrink: 0,
maxWidth: '38rem',
width: '100%',
padding: '1rem 3%',
marginTop: 'auto',
lg: {
margin: 0,
textAlign: 'left',
alignItems: 'flex-start',
flexShrink: '1',
flexBasis: '36rem',
maxWidth: '36rem',
padding: '1em 3em',
},
})}
>
{children}
</div>
)
}
const RightColumn = ({ children }: { children?: ReactNode }) => {
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
overflow: 'hidden',
padding: '1rem 3%',
marginBottom: 'auto',
flexBasis: 'auto',
flexShrink: 0,
maxWidth: '39rem',
lg: {
margin: 0,
flexBasis: '45%',
padding: '1em 3em',
},
})}
>
{children}
</div>
)
}
const Separator = styled('div', {
base: {
borderBottom: '1px solid',
borderColor: '#747775',
marginTop: '2.5rem',
maxWidth: '30rem',
width: '100%',
},
})
const Heading = styled('h1', {
base: {
fontWeight: '500',
fontStyle: 'normal',
fontStretch: 'normal',
fontOpticalSizing: 'auto',
marginBottom: 0,
paddingBottom: '0.75rem',
fontSize: '2.3rem',
lineHeight: '2.5rem',
letterSpacing: '0',
xsm: {
fontSize: '3rem',
lineHeight: '3.2rem',
},
},
})
const IntroText = styled('div', {
base: {
marginBottom: '3rem',
fontSize: '1.5rem',
lineHeight: '1.8rem',
textWrap: 'pretty',
maxWidth: '32rem',
},
})
export const Home = () => {
const { t } = useTranslation('home')
@@ -30,72 +154,90 @@ export const Home = () => {
return (
<UserAware>
<Screen>
<Centered width="fit-content">
<Text as="h1" variant="display">
{t('heading')}
</Text>
<Text as="p" variant="h3">
{t('intro')}
</Text>
{!isLoggedIn && (
<Text margin="sm" variant="note">
{t('loginToCreateMeeting')}
</Text>
)}
<HStack gap="gutter" alignItems="start">
{isLoggedIn ? (
<Menu>
<Button variant="primary" data-attr="create-meeting">
{t('createMeeting')}
<Columns>
<LeftColumn>
<Heading>{t('heading')}</Heading>
<IntroText>{t('intro')}</IntroText>
<div
className={css({
display: 'flex',
gap: 0.5,
flexDirection: { base: 'column', xsm: 'row' },
alignItems: { base: 'center', xsm: 'items-start' },
})}
>
{isLoggedIn ? (
<Menu>
<Button variant="primary" data-attr="create-meeting">
{t('createMeeting')}
</Button>
<RACMenu>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-instant"
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
</MenuItem>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoomId(data.slug)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
<ProConnectButton hint={false} />
)}
<DialogTrigger>
<Button
variant="primary"
outline
style={{
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
}}
>
{t('joinMeeting')}
</Button>
<RACMenu>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-instant"
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
</MenuItem>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoomId(data.slug)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
<ProConnectButton />
)}
<DialogTrigger>
<Button
variant="primary"
outline
style={{
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
}}
>
{t('joinMeeting')}
</Button>
<JoinMeetingDialog />
</DialogTrigger>
</HStack>
</Centered>
<JoinMeetingDialog />
</DialogTrigger>
</div>
<Separator />
<div
className={css({
display: { base: 'none', lg: 'inline' },
})}
>
<MoreLink />
</div>
</LeftColumn>
<RightColumn>
<IntroSlider />
<div
className={css({
display: { base: 'inline', lg: 'none' },
})}
>
<MoreLink />
</div>
</RightColumn>
</Columns>
<LaterMeetingDialog
roomId={laterRoomId || ''}
onOpenChange={() => setLaterRoomId(null)}
@@ -93,7 +93,7 @@ export const Conference = ({
// These settings help ensure successful connections in poor network conditions
const connectOptions = {
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
peerConnectionTimeout: 60, // Default: 15s. Extended for slow TURN/TLS negotiation
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
}
return (
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { useUser } from '@/features/auth'
export const Join = ({
onSubmit,
@@ -9,6 +10,7 @@ export const Join = ({
onSubmit: (choices: LocalUserChoices) => void
}) => {
const { t } = useTranslation('rooms')
const { user } = useUser()
return (
<Screen layout="centered">
@@ -20,6 +22,7 @@ export const Join = ({
camLabel={t('join.videoinput.label')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.usernameLabel')}
defaults={{ username: user?.full_name }}
/>
</CenteredContent>
</Screen>
@@ -0,0 +1,249 @@
import { Button, H, Text, TextArea } from '@/primitives'
import { useEffect, useState } from 'react'
import { cva } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { styled, VStack } from '@/styled-system/jsx'
import { usePostHog } from 'posthog-js/react'
import { PostHog } from 'posthog-js'
const Card = styled('div', {
base: {
border: '1px solid',
borderColor: 'gray.300',
padding: '1rem',
marginTop: '1.5rem',
borderRadius: '0.25rem',
boxShadow: '',
minWidth: '380px',
minHeight: '196px',
},
})
const Bar = styled('div', {
base: {
display: 'flex',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: '8px',
overflowY: 'hidden',
scrollbar: 'hidden',
},
})
const ratingButtonRecipe = cva({
base: {
backgroundColor: 'white',
color: 'initial',
border: 'none',
borderRadius: 0,
padding: '0.5rem 0.85rem',
flexGrow: '1',
cursor: 'pointer',
},
variants: {
selected: {
true: {
backgroundColor: '#1d4ed8',
color: 'white',
},
false: {
'&[data-hovered]': {
backgroundColor: 'gray.100',
},
},
},
borderLeft: {
true: {
borderLeft: '1px solid',
borderColor: 'gray.300',
},
},
},
})
const labelRecipe = cva({
base: {
color: 'gray.600',
paddingTop: '0.25rem',
},
})
const OpenFeedback = ({
posthog,
onNext,
}: {
posthog: PostHog
onNext: () => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'openFeedback' })
const [feedback, setFeedback] = useState('')
const onContinue = () => {
setFeedback('')
onNext()
}
const onSubmit = () => {
try {
posthog.capture('survey sent', {
$survey_id: '01933c5a-5a1d-0000-ada8-e39f5918c2d4',
$survey_response: feedback,
})
} catch (e) {
console.warn(e)
} finally {
onContinue()
}
}
return (
<Card>
<H lvl={3}>{t('question')}</H>
<TextArea
id="feedbackInput"
name="feedback"
placeholder={t('placeholder')}
required
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
style={{
minHeight: '150px',
marginBottom: '1rem',
}}
/>
<VStack gap="0.5">
<Button
variant="primary"
size="sm"
fullWidth
isDisabled={!feedback}
onPress={onSubmit}
>
{t('submit')}
</Button>
<Button invisible size="sm" fullWidth onPress={onNext}>
{t('skip')}
</Button>
</VStack>
</Card>
)
}
const RateQuality = ({
posthog,
onNext,
maxRating = 7,
}: {
posthog: PostHog
onNext: () => void
maxRating?: number
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'rating' })
const [selectedRating, setSelectedRating] = useState<number | null>(null)
const handleRatingClick = (rating: number) => {
setSelectedRating((prevRating) => (prevRating === rating ? null : rating))
}
const onSubmit = () => {
try {
posthog.capture('survey sent', {
$survey_id: '01933c22-d005-0000-b623-20b752171e2e',
$survey_response: `${selectedRating}`,
})
} catch (e) {
console.warn(e)
} finally {
setSelectedRating(null)
onNext()
}
}
return (
<Card>
<H lvl={3}>{t('question')}</H>
<Bar>
{[...Array(maxRating)].map((_, index) => (
<Button
key={index}
onPress={() => handleRatingClick(index + 1)}
className={ratingButtonRecipe({
selected: selectedRating === index + 1,
borderLeft: index != 0,
})}
>
{index + 1}
</Button>
))}
</Bar>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '1rem',
}}
>
<Text variant="sm" className={labelRecipe()}>
{t('levels.min')}
</Text>
<Text variant="sm" className={labelRecipe()}>
{t('levels.max')}
</Text>
</div>
<Button
variant="primary"
size="sm"
fullWidth
isDisabled={!selectedRating}
onPress={onSubmit}
>
{t('submit')}
</Button>
</Card>
)
}
const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'confirmationMessage' })
useEffect(() => {
const timer = setTimeout(() => {
onNext()
}, 10000)
return () => clearTimeout(timer)
}, [onNext])
return (
<Card
style={{
maxWidth: '380px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<VStack gap={0}>
<H lvl={3}>{t('heading')}</H>
<Text as="p" variant="paragraph" centered>
{t('body')}
</Text>
</VStack>
</Card>
)
}
export const Rating = () => {
const posthog = usePostHog()
const [step, setStep] = useState(0)
if (step == 0) {
return <RateQuality posthog={posthog} onNext={() => setStep(step + 1)} />
}
if (step == 1) {
return <OpenFeedback posthog={posthog} onNext={() => setStep(step + 1)} />
}
if (step == 2) {
return <ConfirmationMessage onNext={() => setStep(0)} />
}
}
@@ -1,8 +1,7 @@
import { Button } from '@/primitives'
import { Button, TextArea } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { RiSendPlane2Fill } from '@remixicon/react'
import { useState, useEffect } from 'react'
import { TextArea } from '@/primitives/TextArea'
import { RefObject } from 'react'
import { useTranslation } from 'react-i18next'
@@ -149,7 +149,13 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const { isSidePanelOpen } = useSidePanel()
return (
<div className="lk-video-conference" {...props}>
<div
className="lk-video-conference"
{...props}
style={{
overflowX: 'hidden',
}}
>
{isWeb() && (
<LayoutContextProvider
value={layoutContext}
@@ -1,17 +1,47 @@
import { useTranslation } from 'react-i18next'
import { Text } from '@/primitives'
import { Button } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter'
// fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', {
base: {
fontWeight: '500',
fontStyle: 'normal',
fontStretch: 'normal',
fontOpticalSizing: 'auto',
fontSize: '2.3rem',
lineHeight: '2.5rem',
letterSpacing: '0',
paddingBottom: '2rem',
},
})
export const FeedbackRoute = () => {
const { t } = useTranslation('rooms')
const [, setLocation] = useLocation()
return (
<Screen layout="centered">
<CenteredContent title={t('feedback.heading')} withBackButton>
<Text as="p" variant="h3" centered>
{t('feedback.body')}
</Text>
</CenteredContent>
<Center>
<VStack>
<Heading>{t('feedback.heading')}</Heading>
<HStack>
<Button
outline
variant="primary"
onPress={() => window.history.back()}
>
{t('feedback.back')}
</Button>
<Button variant="primary" onPress={() => setLocation('/')}>
{t('feedback.home')}
</Button>
</HStack>
<Rating />
</VStack>
</Center>
</Screen>
)
}
@@ -18,7 +18,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
<P>
<Trans
i18nKey="settings:account.currentlyLoggedAs"
values={{ user: user?.email }}
values={{ user: user?.full_name || user?.email }}
components={[<Badge />]}
/>
</P>
@@ -48,7 +48,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
<P>
<Trans
i18nKey="settings:account.currentlyLoggedAs"
values={{ user: user?.email }}
values={{ user: user?.full_name || user?.email }}
components={[<Badge />]}
/>
</P>
+11 -8
View File
@@ -13,11 +13,14 @@ i18n
)
.use(initReactI18next)
.use(LanguageDetector)
i18n.init({
supportedLngs: ['en', 'fr'],
fallbackLng: 'en',
ns: i18nDefaultNamespace,
interpolation: {
escapeValue: false,
},
})
.init({
supportedLngs: ['en', 'fr'],
fallbackLng: 'fr',
ns: i18nDefaultNamespace,
detection: {
order: ['localStorage', 'navigator'],
},
interpolation: {
escapeValue: false,
},
})
+72 -67
View File
@@ -6,7 +6,7 @@ import { Text, Button } from '@/primitives'
import { SettingsButton } from '@/features/settings'
import { logoutUrl, useUser } from '@/features/auth'
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
import { Feedback } from '@/components/Feedback'
import { FeedbackBanner } from '@/components/FeedbackBanner.tsx'
import { Menu } from '@/primitives/Menu'
import { MenuList } from '@/primitives/MenuList'
import { ProConnectButton } from '@/components/ProConnectButton'
@@ -20,80 +20,85 @@ export const Header = () => {
const { user, isLoggedIn } = useUser()
return (
<div
className={css({
borderBottomColor: 'box.border',
borderBottomWidth: 1,
borderBottomStyle: 'solid',
paddingY: 1,
paddingX: 1,
flexShrink: 0,
})}
>
<>
<FeedbackBanner />
<div
className={css({
display: 'flex',
flexDirection: 'column',
rowGap: 1,
md: {
paddingY: 1,
paddingX: 1,
flexShrink: 0,
})}
>
<div
className={css({
display: 'flex',
rowGap: 0,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
})}
>
<header>
<Stack gap={2.25} direction="row" align="center">
<Text bold variant="h1" margin={false}>
<Link
onClick={(event) => {
if (
isRoom &&
!window.confirm(t('leaveRoomPrompt', { ns: 'rooms' }))
) {
event.preventDefault()
}
}}
to="/"
>
{t('app')}
</Link>
</Text>
<Feedback />
</Stack>
</header>
<nav>
<Stack gap={1} direction="row" align="center">
{isLoggedIn === false && !isHome && (
<ProConnectButton hint={false} />
)}
{!!user && (
<Menu>
<Button
size="sm"
invisible
tooltip={t('loggedInUserTooltip')}
tooltipType="delayed"
>
{user.email}
</Button>
<MenuList
items={[{ value: 'logout', label: t('logout') }]}
onAction={(value) => {
if (value === 'logout') {
terminateAnalyticsSession()
terminateSupportSession()
window.location.href = logoutUrl()
})}
>
<header>
<Stack gap={2.25} direction="row" align="center">
<Text bold variant="h1" margin={false}>
<Link
onClick={(event) => {
if (
isRoom &&
!window.confirm(t('leaveRoomPrompt', { ns: 'rooms' }))
) {
event.preventDefault()
}
}}
/>
</Menu>
)}
<SettingsButton />
</Stack>
</nav>
to="/"
>
{t('app')}
</Link>
</Text>
</Stack>
</header>
<nav>
<Stack gap={1} direction="row" align="center">
{isLoggedIn === false && !isHome && (
<ProConnectButton hint={false} />
)}
{!!user && (
<Menu>
<Button
size="sm"
invisible
tooltip={t('loggedInUserTooltip')}
tooltipType="delayed"
>
<span
className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
maxWidth: { base: '80px', xsm: '350px' },
})}
>
{user?.full_name || user?.email}
</span>
</Button>
<MenuList
items={[{ value: 'logout', label: t('logout') }]}
onAction={(value) => {
if (value === 'logout') {
terminateAnalyticsSession()
terminateSupportSession()
window.location.href = logoutUrl()
}
}}
/>
</Menu>
)}
<SettingsButton />
</Stack>
</nav>
</div>
</div>
</div>
</>
)
}
+4 -1
View File
@@ -6,7 +6,10 @@
"error": {
"heading": ""
},
"feedbackAlert": "",
"feedback": {
"context": "",
"cta": ""
},
"forbidden": {
"heading": ""
},
+32
View File
@@ -10,6 +10,9 @@
"joinMeetingTipContent": "",
"joinMeetingTipHeading": "",
"loginToCreateMeeting": "",
"moreLinkLabel": "",
"moreLink": "",
"moreAbout": "",
"createMenu": {
"laterOption": "",
"instantOption": ""
@@ -20,5 +23,34 @@
"copy": "",
"copied": "",
"permissions": ""
},
"introSlider": {
"previous": {
"label": "",
"tooltip": ""
},
"beta": {
"text": "",
"tooltip": ""
},
"next": {
"label": "",
"tooltip": ""
},
"slide1": {
"title": "",
"body": "",
"imgAlt": ""
},
"slide2": {
"title": "",
"body": "",
"imgAlt": ""
},
"slide3": {
"title": "",
"body": "",
"imgAlt": ""
}
}
}
+21 -2
View File
@@ -1,7 +1,8 @@
{
"feedback": {
"body": "",
"heading": ""
"heading": "",
"home": "",
"back": ""
},
"join": {
"videoinput": {
@@ -109,6 +110,24 @@
"chat": {
"disclaimer": ""
},
"rating": {
"submit": "",
"question": "",
"levels": {
"min": "",
"max": ""
}
},
"openFeedback": {
"question": "",
"placeholder": "",
"submit": "",
"skip": ""
},
"confirmationMessage": {
"heading": "",
"body": ""
},
"participants": {
"subheading": "",
"contributors": "",
+4 -1
View File
@@ -6,7 +6,10 @@
"error": {
"heading": "An error occurred while loading the page"
},
"feedbackAlert": "Give us feedback",
"feedback": {
"context": "Visio is still in early development — your input matters!",
"cta": "Share your feedback"
},
"forbidden": {
"heading": "You don't have the permission to view this page"
},
+34 -2
View File
@@ -1,7 +1,7 @@
{
"createMeeting": "Create a meeting",
"heading": "Welcome in Visio",
"intro": "Work easily, from anywhere.",
"heading": "Simple and Secure Video Conferencing",
"intro": "Communicate and work with ease, without compromising your sovereignty",
"joinInputError": "Use a meeting link or code. Examples:",
"joinInputExample": "URL or 10-letter code",
"joinInputLabel": "Meeting link",
@@ -10,6 +10,9 @@
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
"joinMeetingTipHeading": "Did you know?",
"loginToCreateMeeting": "Login to create a meeting",
"moreLinkLabel": "Learn more - new tab",
"moreLink": "Learn more",
"moreAbout": "about Visio",
"createMenu": {
"laterOption": "Create a meeting for a later date",
"instantOption": "Start an instant meeting"
@@ -20,5 +23,34 @@
"copy": "Copy the meeting link",
"copied": "Link copied to clipboard",
"permissions": "People with this link do not need your permission to join this meeting."
},
"introSlider": {
"previous": {
"label": "previous",
"tooltip": "previous"
},
"next": {
"label": "next",
"tooltip": "next"
},
"beta": {
"text": "Join the beta",
"tooltip": "Fill out the form"
},
"slide1": {
"title": "Try Visio to simplify your daily tasks",
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more.",
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
},
"slide2": {
"title": "Host group calls without limits",
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size.",
"imgAlt": "Image of a virtual meeting with multiple participants collaborating seamlessly"
},
"slide3": {
"title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!",
"imgAlt": "Illustration of AI-powered note-taking in a virtual meeting"
}
}
}
+21 -2
View File
@@ -1,7 +1,8 @@
{
"feedback": {
"body": "Please fill out the form available in the header to give us your precious feedback! Thanks.",
"heading": "Help us improve Visio"
"heading": "You have left the meeting",
"home": "Return to home",
"back": "Rejoin the meeting"
},
"join": {
"videoinput": {
@@ -107,6 +108,24 @@
"chat": {
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
},
"rating": {
"submit": "Submit",
"question": "What do you think about the quality of your call?",
"levels": {
"min": "very poor",
"max": "excellent"
}
},
"openFeedback": {
"question": "What can we do to improve Visio?",
"placeholder": "Describe your bugs or share your suggestions…",
"submit": "Submit",
"skip": "Skip"
},
"confirmationMessage": {
"heading": "Thank you for your submission",
"body": "Our product team takes the time to carefully review your feedback. We will get back to you as soon as possible."
},
"participants": {
"subheading": "In room",
"you": "You",
+4 -1
View File
@@ -6,7 +6,10 @@
"error": {
"heading": "Une erreur est survenue lors du chargement de la page"
},
"feedbackAlert": "Donnez-nous votre avis",
"feedback": {
"context": "Visio est en pleine construction — votre avis compte !",
"cta": "Partagez votre avis"
},
"forbidden": {
"heading": "Accès interdit"
},
+34 -2
View File
@@ -1,7 +1,7 @@
{
"createMeeting": "Créer une réunion",
"heading": "Visio",
"intro": "Collaborez en toute simplicité, où que vous soyez.",
"heading": "Visioconférences simples et sécurisées",
"intro": "Communiquez et travaillez en toute simplicité, sans compromis sur votre souveraineté",
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
"joinInputLabel": "Lien complet ou code de la réunion",
@@ -10,6 +10,9 @@
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
"joinMeetingTipHeading": "Astuce",
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
"moreLink": "En savoir plus",
"moreAbout": "sur Visio",
"createMenu": {
"laterOption": "Créer une réunion pour une date ultérieure",
"instantOption": "Démarrer une réunion instantanée"
@@ -20,5 +23,34 @@
"copy": "Copier le lien de la réunion",
"copied": "Lien copié dans le presse-papiers",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
},
"introSlider": {
"previous": {
"label": "précédent",
"tooltip": "précédent"
},
"next": {
"label": "suivant",
"tooltip": "suivant"
},
"beta": {
"text": "Essayer la beta",
"tooltip": "Accéder au formulaire"
},
"slide1": {
"title": "Essayez Visio pour simplifier votre quotidien",
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore.",
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
},
"slide2": {
"title": "Organisez des appels de groupe sans limite",
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre.",
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
},
"slide3": {
"title": "Transformez vos réunions avec l'IA",
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !",
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
}
}
}
+21 -2
View File
@@ -1,7 +1,8 @@
{
"feedback": {
"body": "Remplissez le formulaire disponible dans l'entête du site pour nous donner votre avis sur l'outil. Vos retours sont précieux ! Merci.",
"heading": "Aidez-nous à améliorer Visio"
"heading": "Vous avez quitté la réunion",
"home": "Retourner à l'accueil",
"back": "Réintégrer la réunion"
},
"join": {
"videoinput": {
@@ -107,6 +108,24 @@
"chat": {
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
},
"rating": {
"submit": "Envoyer",
"question": "Que pensez-vous de la qualité de votre appel ?",
"levels": {
"min": "très mauvaise",
"max": "excellente"
}
},
"openFeedback": {
"question": "Que pouvons-nous faire pour améliorer Visio ?",
"placeholder": "Décrivez vos bugs ou partagez vos suggestions …",
"submit": "Envoyer",
"skip": "Passer"
},
"confirmationMessage": {
"heading": "Merci pour votre submission",
"body": "Notre équipe produit prend le temps d'analyser attentivement vos réponses. Nous reviendrons vers vous dans les plus brefs délais."
},
"participants": {
"subheading": "Dans la réunion",
"you": "Vous",
+1 -1
View File
@@ -9,7 +9,7 @@ const link = cva({
borderRadius: 2,
transition: 'all 0.2s',
'&[data-hovered]': {
textDecoration: 'none',
textDecorationThickness: '2px',
},
'&[data-pressed]': {
textDecoration: 'underline',
@@ -59,6 +59,9 @@ export const buttonRecipe = cva({
},
primary: {
colorPalette: 'primary',
'&[data-disabled]': {
opacity: 0.3,
},
},
// @TODO: better handling of colors… this is a mess
success: {
+1
View File
@@ -28,3 +28,4 @@ export { Text } from './Text'
export { ToggleButton } from './ToggleButton'
export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'
export { TextArea } from './TextArea'
+59 -1
View File
@@ -24,7 +24,7 @@ backend:
OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }}
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_RP_SCOPES: "openid email given_name usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
@@ -108,3 +108,61 @@ posthog:
ingressAssets:
enabled: false
summary:
replicas: 1
envVars:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
AWS_S3_SECURE_ACCESS: False
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
image:
repository: localhost:5001/meet-summary
pullPolicy: Always
tag: "latest"
command:
- "uvicorn"
- "summary.main:app"
- "--host"
- "0.0.0.0"
- "--port"
- "8000"
- "--reload"
celery:
replicas: 1
envVars:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
AWS_S3_SECURE_ACCESS: False
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
image:
repository: localhost:5001/meet-summary
pullPolicy: Always
tag: "latest"
command:
- "celery"
- "-A"
- "summary.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v0.1.7"
tag: "v0.1.8"
backend:
migrateJobAnnotations:
@@ -42,7 +42,7 @@ backend:
name: backend
key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_RP_SCOPES: "openid email given_name usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://visio.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://visio.numerique.gouv.fr
@@ -128,7 +128,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v0.1.7"
tag: "v0.1.8"
ingress:
enabled: true
+105 -1
View File
@@ -41,7 +41,7 @@ backend:
name: backend
key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_RP_SCOPES: "openid email given_name usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://visio-staging.beta.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://visio-staging.beta.numerique.gouv.fr
@@ -174,3 +174,107 @@ posthog:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/upstream-vhost: eu-assets.i.posthog.com
nginx.ingress.kubernetes.io/backend-protocol: https
summary:
replicas: 1
envVars:
APP_NAME: summary-microservice
APP_API_TOKEN:
secretKeyRef:
name: summary
key: APP_API_TOKEN
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: url
AWS_S3_ACCESS_KEY_ID:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: accessKey
AWS_S3_SECRET_ACCESS_KEY:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: secretKey
AWS_STORAGE_BUCKET_NAME:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
OPENAI_API_KEY:
secretKeyRef:
name: summary
key: OPENAI_API_KEY
WEBHOOK_API_TOKEN:
secretKeyRef:
name: summary
key: WEBHOOK_API_TOKEN
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL:
secretKeyRef:
name: redis-summary.redis.libre.sh
key: url
CELERY_RESULT_BACKEND:
secretKeyRef:
name: redis-summary.redis.libre.sh
key: url
image:
repository: lasuite/meet-summary
pullPolicy: Always
tag: "main"
celery:
replicas: 1
envVars:
APP_NAME: summary-microservice
APP_API_TOKEN:
secretKeyRef:
name: summary
key: APP_API_TOKEN
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: url
AWS_S3_ACCESS_KEY_ID:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: accessKey
AWS_S3_SECRET_ACCESS_KEY:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: secretKey
AWS_STORAGE_BUCKET_NAME:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
OPENAI_API_KEY:
secretKeyRef:
name: summary
key: OPENAI_API_KEY
WEBHOOK_API_TOKEN:
secretKeyRef:
name: summary
key: WEBHOOK_API_TOKEN
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL:
secretKeyRef:
name: redis-summary.redis.libre.sh
key: url
CELERY_RESULT_BACKEND:
secretKeyRef:
name: redis-summary.redis.libre.sh
key: url
image:
repository: lasuite/meet-summary
pullPolicy: Always
tag: "main"
command:
- "celery"
- "-A"
- "summary.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
+1 -1
View File
@@ -1,7 +1,7 @@
apiVersion: core.libre.sh/v1alpha1
kind: Bucket
metadata:
name: impress-media-storage
name: meet-media-storage
namespace: {{ .Release.Namespace | quote }}
spec:
provider: data
@@ -0,0 +1,7 @@
apiVersion: core.libre.sh/v1alpha1
kind: Redis
metadata:
name: redis-summary
namespace: {{ .Release.Namespace | quote }}
spec:
disableAuth: false
+18
View File
@@ -166,6 +166,24 @@ Requires top level scope
{{ include "meet.fullname" . }}-posthog
{{- end }}
{{/*
Full name for the summary
Requires top level scope
*/}}
{{- define "meet.summary.fullname" -}}
{{ include "meet.fullname" . }}-summary
{{- end }}
{{/*
Full name for the Celery
Requires top level scope
*/}}
{{- define "meet.celery.fullname" -}}
{{ include "meet.fullname" . }}-celery
{{- end }}
{{/*
Usage : {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" .Values.path.to.the.image1) }}
*/}}
@@ -0,0 +1,137 @@
{{- $envVars := include "meet.common.env" (list . .Values.celery) -}}
{{- $fullName := include "meet.celery.fullname" . -}}
{{- $component := "celery" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
spec:
replicas: {{ .Values.celery.replicas }}
selector:
matchLabels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
template:
metadata:
annotations:
{{- with .Values.celery.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
checksum/config: {{ include (print $.Template.BasePath "/secrets.yaml") . | sha256sum }}
labels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 8 }}
spec:
{{- if $.Values.image.credentials }}
imagePullSecrets:
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
{{- end}}
shareProcessNamespace: {{ .Values.celery.shareProcessNamespace }}
containers:
{{- with .Values.celery.sidecars }}
{{- toYaml . | nindent 8 }}
{{- end }}
- name: {{ .Chart.Name }}
image: "{{ (.Values.celery.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.celery.image | default dict).tag | default .Values.image.tag }}"
imagePullPolicy: {{ (.Values.celery.image | default dict).pullPolicy | default .Values.image.pullPolicy }}
{{- with .Values.celery.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.celery.args }}
args:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars}}
{{- $envVars | indent 12 }}
{{- end }}
{{- with .Values.celery.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.celery.service.targetPort }}
protocol: TCP
{{- if .Values.celery.probes.liveness }}
livenessProbe:
{{- include "meet.probes.abstract" (merge .Values.celery.probes.liveness (dict "targetPort" .Values.celery.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.celery.probes.readiness }}
readinessProbe:
{{- include "meet.probes.abstract" (merge .Values.celery.probes.readiness (dict "targetPort" .Values.celery.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.celery.probes.startup }}
startupProbe:
{{- include "meet.probes.abstract" (merge .Values.celery.probes.startup (dict "targetPort" .Values.celery.service.targetPort )) | nindent 12 }}
{{- end }}
{{- with .Values.celery.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
{{- range $index, $value := .Values.mountFiles }}
- name: "files-{{ $index }}"
mountPath: {{ $value.path }}
subPath: content
{{- end }}
{{- range $name, $volume := .Values.celery.persistence }}
- name: "{{ $name }}"
mountPath: "{{ $volume.mountPath }}"
{{- end }}
{{- range .Values.celery.extraVolumeMounts }}
- name: {{ .name }}
mountPath: {{ .mountPath }}
subPath: {{ .subPath | default "" }}
readOnly: {{ .readOnly }}
{{- end }}
{{- with .Values.celery.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.celery.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.celery.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
{{- range $index, $value := .Values.mountFiles }}
- name: "files-{{ $index }}"
configMap:
name: "{{ include "meet.fullname" $ }}-files-{{ $index }}"
{{- end }}
{{- range $name, $volume := .Values.celery.persistence }}
- name: "{{ $name }}"
{{- if eq $volume.type "emptyDir" }}
emptyDir: {}
{{- else }}
persistentVolumeClaim:
claimName: "{{ $fullName }}-{{ $name }}"
{{- end }}
{{- end }}
{{- range .Values.celery.extraVolumes }}
- name: {{ .name }}
{{- if .existingClaim }}
persistentVolumeClaim:
claimName: {{ .existingClaim }}
{{- else if .hostPath }}
hostPath:
{{ toYaml .hostPath | nindent 12 }}
{{- else if .csi }}
csi:
{{- toYaml .csi | nindent 12 }}
{{- else if .configMap }}
configMap:
{{- toYaml .configMap | nindent 12 }}
{{- else if .emptyDir }}
emptyDir:
{{- toYaml .emptyDir | nindent 12 }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
@@ -0,0 +1,137 @@
{{- $envVars := include "meet.common.env" (list . .Values.summary) -}}
{{- $fullName := include "meet.summary.fullname" . -}}
{{- $component := "summary" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
spec:
replicas: {{ .Values.summary.replicas }}
selector:
matchLabels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
template:
metadata:
annotations:
{{- with .Values.summary.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
checksum/config: {{ include (print $.Template.BasePath "/secrets.yaml") . | sha256sum }}
labels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 8 }}
spec:
{{- if $.Values.image.credentials }}
imagePullSecrets:
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
{{- end}}
shareProcessNamespace: {{ .Values.summary.shareProcessNamespace }}
containers:
{{- with .Values.summary.sidecars }}
{{- toYaml . | nindent 8 }}
{{- end }}
- name: {{ .Chart.Name }}
image: "{{ (.Values.summary.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.summary.image | default dict).tag | default .Values.image.tag }}"
imagePullPolicy: {{ (.Values.summary.image | default dict).pullPolicy | default .Values.image.pullPolicy }}
{{- with .Values.summary.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.summary.args }}
args:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars}}
{{- $envVars | indent 12 }}
{{- end }}
{{- with .Values.summary.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.summary.service.targetPort }}
protocol: TCP
{{- if .Values.summary.probes.liveness }}
livenessProbe:
{{- include "meet.probes.abstract" (merge .Values.summary.probes.liveness (dict "targetPort" .Values.summary.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.summary.probes.readiness }}
readinessProbe:
{{- include "meet.probes.abstract" (merge .Values.summary.probes.readiness (dict "targetPort" .Values.summary.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.summary.probes.startup }}
startupProbe:
{{- include "meet.probes.abstract" (merge .Values.summary.probes.startup (dict "targetPort" .Values.summary.service.targetPort )) | nindent 12 }}
{{- end }}
{{- with .Values.summary.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
{{- range $index, $value := .Values.mountFiles }}
- name: "files-{{ $index }}"
mountPath: {{ $value.path }}
subPath: content
{{- end }}
{{- range $name, $volume := .Values.summary.persistence }}
- name: "{{ $name }}"
mountPath: "{{ $volume.mountPath }}"
{{- end }}
{{- range .Values.summary.extraVolumeMounts }}
- name: {{ .name }}
mountPath: {{ .mountPath }}
subPath: {{ .subPath | default "" }}
readOnly: {{ .readOnly }}
{{- end }}
{{- with .Values.summary.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.summary.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.summary.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
{{- range $index, $value := .Values.mountFiles }}
- name: "files-{{ $index }}"
configMap:
name: "{{ include "meet.fullname" $ }}-files-{{ $index }}"
{{- end }}
{{- range $name, $volume := .Values.summary.persistence }}
- name: "{{ $name }}"
{{- if eq $volume.type "emptyDir" }}
emptyDir: {}
{{- else }}
persistentVolumeClaim:
claimName: "{{ $fullName }}-{{ $name }}"
{{- end }}
{{- end }}
{{- range .Values.summary.extraVolumes }}
- name: {{ .name }}
{{- if .existingClaim }}
persistentVolumeClaim:
claimName: {{ .existingClaim }}
{{- else if .hostPath }}
hostPath:
{{ toYaml .hostPath | nindent 12 }}
{{- else if .csi }}
csi:
{{- toYaml .csi | nindent 12 }}
{{- else if .configMap }}
configMap:
{{- toYaml .configMap | nindent 12 }}
{{- else if .emptyDir }}
emptyDir:
{{- toYaml .emptyDir | nindent 12 }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
@@ -0,0 +1,14 @@
apiVersion: v1
kind: Secret
metadata:
name: summary
namespace: {{ .Release.Namespace | quote }}
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation
stringData:
APP_API_TOKEN: {{ .Values.appApiToken }}
OPENAI_API_KEY: {{ .Values.openaiApiKey }}
WEBHOOK_API_TOKEN: {{ .Values.webhookApiToken }}
+21
View File
@@ -0,0 +1,21 @@
{{- $envVars := include "meet.common.env" (list . .Values.summary) -}}
{{- $fullName := include "meet.summary.fullname" . -}}
{{- $component := "summary" -}}
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
annotations:
{{- toYaml $.Values.summary.service.annotations | nindent 4 }}
spec:
type: {{ .Values.summary.service.type }}
ports:
- port: {{ .Values.summary.service.port }}
targetPort: {{ .Values.summary.service.targetPort }}
protocol: TCP
name: http
selector:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 4 }}
+181
View File
@@ -305,3 +305,184 @@ posthog:
externalName: eu-assets.i.posthog.com
port: 443
annotations: {}
## @section summary
summary:
## @param summary.command Override the summary container command
command: []
## @param summary.args Override the summary container args
args: []
## @param summary.replicas Amount of summary replicas
replicas: 1
## @param summary.shareProcessNamespace Enable share process namespace between containers
shareProcessNamespace: false
## @param summary.sidecars Add sidecars containers to summary deployment
sidecars: []
## @param summary.migrateJobAnnotations Annotations for the migrate job
migrateJobAnnotations: {}
## @param summary.securityContext Configure summary Pod security context
securityContext: null
## @param summary.envVars Configure summary container environment variables
## @extra summary.envVars.BY_VALUE Example environment variable by setting value directly
## @extra summary.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
## @extra summary.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap
## @extra summary.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret
## @extra summary.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
## @skip summary.envVars
envVars:
<<: *commonEnvVars
## @param summary.podAnnotations Annotations to add to the summary Pod
podAnnotations: {}
## @param summary.service.type summary Service type
## @param summary.service.port summary Service listening port
## @param summary.service.targetPort summary container listening port
## @param summary.service.annotations Annotations to add to the summary Service
service:
type: ClusterIP
port: 80
targetPort: 8000
annotations: {}
## @param summary.probes.liveness.path [nullable] Configure path for summary HTTP liveness probe
## @param summary.probes.liveness.targetPort [nullable] Configure port for summary HTTP liveness probe
## @param summary.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for summary liveness probe
## @param summary.probes.liveness.initialDelaySeconds [nullable] Configure timeout for summary liveness probe
## @param summary.probes.startup.path [nullable] Configure path for summary HTTP startup probe
## @param summary.probes.startup.targetPort [nullable] Configure port for summary HTTP startup probe
## @param summary.probes.startup.initialDelaySeconds [nullable] Configure initial delay for summary startup probe
## @param summary.probes.startup.initialDelaySeconds [nullable] Configure timeout for summary startup probe
## @param summary.probes.readiness.path [nullable] Configure path for summary HTTP readiness probe
## @param summary.probes.readiness.targetPort [nullable] Configure port for summary HTTP readiness probe
## @param summary.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for summary readiness probe
## @param summary.probes.readiness.initialDelaySeconds [nullable] Configure timeout for summary readiness probe
probes:
liveness:
path: /__heartbeat__
initialDelaySeconds: 30
readiness:
path: /__lbheartbeat__
initialDelaySeconds: 30
## @param summary.resources Resource requirements for the summary container
resources: {}
## @param summary.nodeSelector Node selector for the summary Pod
nodeSelector: {}
## @param summary.tolerations Tolerations for the summary Pod
tolerations: []
## @param summary.affinity Affinity for the summary Pod
affinity: {}
## @param summary.persistence Additional volumes to create and mount on the summary. Used for debugging purposes
## @extra summary.persistence.volume-name.size Size of the additional volume
## @extra summary.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir
## @extra summary.persistence.volume-name.mountPath Path where the volume should be mounted to
persistence: {}
## @param summary.extraVolumeMounts Additional volumes to mount on the summary.
extraVolumeMounts: []
## @param summary.extraVolumes Additional volumes to mount on the summary.
extraVolumes: []
## @section celery
celery:
## @param celery.command Override the celery container command
command: []
## @param celery.args Override the celery container args
args: []
## @param celery.replicas Amount of celery replicas
replicas: 1
## @param celery.shareProcessNamespace Enable share process namespace between containers
shareProcessNamespace: false
## @param celery.sidecars Add sidecars containers to celery deployment
sidecars: []
## @param celery.migrateJobAnnotations Annotations for the migrate job
migrateJobAnnotations: {}
## @param celery.securityContext Configure celery Pod security context
securityContext: null
## @param celery.envVars Configure celery container environment variables
## @extra celery.envVars.BY_VALUE Example environment variable by setting value directly
## @extra celery.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
## @extra celery.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap
## @extra celery.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret
## @extra celery.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
## @skip celery.envVars
envVars:
<<: *commonEnvVars
## @param celery.podAnnotations Annotations to add to the celery Pod
podAnnotations: {}
## @param celery.service.type celery Service type
## @param celery.service.port celery Service listening port
## @param celery.service.targetPort celery container listening port
## @param celery.service.annotations Annotations to add to the celery Service
service:
type: ClusterIP
port: 80
targetPort: 8000
annotations: {}
## @param celery.probes.liveness.path [nullable] Configure path for celery HTTP liveness probe
## @param celery.probes.liveness.targetPort [nullable] Configure port for celery HTTP liveness probe
## @param celery.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for celery liveness probe
## @param celery.probes.liveness.initialDelaySeconds [nullable] Configure timeout for celery liveness probe
## @param celery.probes.startup.path [nullable] Configure path for celery HTTP startup probe
## @param celery.probes.startup.targetPort [nullable] Configure port for celery HTTP startup probe
## @param celery.probes.startup.initialDelaySeconds [nullable] Configure initial delay for celery startup probe
## @param celery.probes.startup.initialDelaySeconds [nullable] Configure timeout for celery startup probe
## @param celery.probes.readiness.path [nullable] Configure path for celery HTTP readiness probe
## @param celery.probes.readiness.targetPort [nullable] Configure port for celery HTTP readiness probe
## @param celery.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for celery readiness probe
## @param celery.probes.readiness.initialDelaySeconds [nullable] Configure timeout for celery readiness probe
probes: {}
## @param celery.resources Resource requirements for the celery container
resources: {}
## @param celery.nodeSelector Node selector for the celery Pod
nodeSelector: {}
## @param celery.tolerations Tolerations for the celery Pod
tolerations: []
## @param celery.affinity Affinity for the celery Pod
affinity: {}
## @param celery.persistence Additional volumes to create and mount on the celery. Used for debugging purposes
## @extra celery.persistence.volume-name.size Size of the additional volume
## @extra celery.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir
## @extra celery.persistence.volume-name.mountPath Path where the volume should be mounted to
persistence: {}
## @param celery.extraVolumeMounts Additional volumes to mount on the celery.
extraVolumeMounts: []
## @param celery.extraVolumes Additional volumes to mount on the celery.
extraVolumes: []
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.7",
"version": "0.1.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.7",
"version": "0.1.8",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.7",
"version": "0.1.8",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.12-slim AS builder
WORKDIR /app
COPY pyproject.toml .
RUN pip3 install --no-cache-dir .
FROM python:3.12-slim AS production
WORKDIR /app
COPY --from=builder /usr/local /usr/local
COPY ./summary /app/summary
CMD ["uvicorn", "summary.main:app", "--proxy-headers", "--host", "0.0.0.0", "--port", "8000"]
+27
View File
@@ -0,0 +1,27 @@
services:
redis:
image: redis
ports:
- "6379:6379"
app:
container_name: app
build: .
command: uvicorn summary.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
- .:/app
ports:
- "8000:8000"
restart: always
env_file:
".env"
depends_on:
- redis
celery_worker:
container_name: celery_worker
build: .
command: celery -A summary.celery_worker worker --pool=solo --loglevel=debug
volumes:
- .:/app
depends_on:
- redis
- app
+64
View File
@@ -0,0 +1,64 @@
[project]
name = "summary"
version = "0.1.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"celery==5.4.0",
"redis==4.5.4",
"minio==7.2.9",
"openai==1.51.2",
"requests==2.32.3",
]
[project.optional-dependencies]
dev = [
"ruff==0.7.4",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.ruff]
target-version = "py310"
[tool.ruff.lint]
select = [
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"D", # pydocstyle
"E", # pycodestyle error
"F", # Pyflakes
"I", # Isort
"ISC", # flake8-implicit-str-concat
"PLC", # Pylint Convention
"PLE", # Pylint Error
"PLR", # Pylint Refactor
"PLW", # Pylint Warning
"RUF100", # Ruff unused-noqa
"S", # flake8-bandit
"T20", # flake8-print
"W", # pycodestyle warning
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"S101", # use of assert
]
[tool.ruff.lint.pydocstyle]
# Use Google-style docstrings.
convention = "google"
[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.params.Depends",
"fastapi.params.Query",
"fastapi.Query",
]
+1
View File
@@ -0,0 +1 @@
"""Summary package."""
+127
View File
@@ -0,0 +1,127 @@
"""Celery workers."""
import json
import tempfile
from pathlib import Path
import openai
from celery import Celery
from celery.utils.log import get_task_logger
from minio import Minio
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from .config import Settings
from .prompt import get_instructions
settings = Settings()
logger = get_task_logger(__name__)
celery = Celery(
__name__,
broker=settings.celery_broker_url,
backend=settings.celery_result_backend,
broker_connection_retry_on_startup=True,
)
def save_audio_stream(audio_stream, chunk_size=32 * 1024):
"""Save an audio stream to a temporary OGG file."""
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
tmp.writelines(audio_stream.stream(chunk_size))
return Path(tmp.name)
def create_retry_session():
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
return session
def post_with_retries(url, data):
"""Send POST request with automatic retries."""
session = create_retry_session()
session.headers.update({"Authorization": f"Bearer {settings.webhook_api_token}"})
try:
response = session.post(url, json=data)
response.raise_for_status()
return response
finally:
session.close()
@celery.task(max_retries=1)
def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
"""Process an audio file by transcribing it and generating a summary.
This Celery task performs the following operations:
1. Retrieves the audio file from MinIO storage
2. Transcribes the audio using OpenAI-compliant API's ASR model
3. Generates a summary of the transcription using OpenAI-compliant API's LLM
4. Sends the results via webhook
"""
logger.info("Notification received")
logger.debug("filename: %s", filename)
minio_client = Minio(
settings.aws_s3_endpoint_url,
access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key,
secure=settings.aws_s3_secure_access,
)
logger.debug("Connection to the Minio bucket successful")
audio_file_stream = minio_client.get_object(
settings.aws_storage_bucket_name, object_name=filename
)
temp_file_path = save_audio_stream(audio_file_stream)
logger.debug("Recording successfully downloaded, filepath: %s", temp_file_path)
logger.debug("Initiating OpenAI client")
openai_client = openai.OpenAI(
api_key=settings.openai_api_key, base_url=settings.openai_base_url
)
logger.debug("Querying transcription …")
with open(temp_file_path, "rb") as audio_file:
transcription = openai_client.audio.transcriptions.create(
model=settings.openai_asr_model, file=audio_file
)
transcription = transcription.text
logger.debug("Transcription: \n %s", transcription)
instructions = get_instructions(transcription)
summary_response = openai_client.chat.completions.create(
model=settings.openai_llm_model, messages=instructions
)
summary = summary_response.choices[0].message.content
logger.debug("Summary: \n %s", summary)
data = {
"summary": summary,
"email": email,
"sub": sub,
}
logger.debug("Submitting webhook to %s", settings.webhook_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
response = post_with_retries(settings.webhook_url, data)
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
logger.debug("Response body: %s", response.text)
+48
View File
@@ -0,0 +1,48 @@
"""Application configuration and settings."""
from functools import lru_cache
from typing import Annotated
from fastapi import Depends
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Configuration settings loaded from environment variables and .env file."""
app_name: str = "Awesome API"
model_config = SettingsConfigDict(env_file=".env")
app_api_token: str
# Celery settings
celery_broker_url: str = "redis://redis/0"
celery_result_backend: str = "redis://redis/0"
# Minio settings
aws_storage_bucket_name: str
aws_s3_endpoint_url: str
aws_s3_access_key_id: str
aws_s3_secret_access_key: str
aws_s3_secure_access: bool = True
# AI-related settings
openai_api_key: str
openai_base_url: str = "https://api.openai.com/v1"
openai_asr_model: str = "whisper-1"
openai_llm_model: str = "gpt-4o"
# Webhook-related settings
webhook_max_retries: int = 2
webhook_status_forcelist: list[int] = [502, 503, 504]
webhook_backoff_factor: float = 0.1
webhook_api_token: str
webhook_url: str
@lru_cache
def get_settings():
"""Load and cache application settings."""
return Settings()
SettingsDeps = Annotated[Settings, Depends(get_settings)]
+46
View File
@@ -0,0 +1,46 @@
"""Application endpoint."""
from celery.result import AsyncResult
from fastapi import Depends, FastAPI
from pydantic import BaseModel
from .celery_worker import process_audio_transcribe_summarize
from .security import verify_token
app = FastAPI()
@app.get("/__heartbeat__")
async def heartbeat():
"""Health check endpoint for monitoring."""
return {"status": "ok"}
@app.get("/__lbheartbeat__")
async def lbheartbeat():
"""Health check endpoint for load balancer."""
return {"status": "ok"}
class NotificationRequest(BaseModel):
"""Notification data."""
filename: str
email: str
sub: str
@app.post("/push")
async def notify(request: NotificationRequest, token: str = Depends(verify_token)):
"""Push a notification."""
task = process_audio_transcribe_summarize.delay(
request.filename, request.email, request.sub
)
return {"task_id": task.id, "message": "Notification sent"}
@app.get("/status/{task_id}")
async def get_status(task_id: str, token: str = Depends(verify_token)):
"""Check task status by ID."""
task = AsyncResult(task_id)
return {"task_id": task_id, "status": task.status}
+52
View File
@@ -0,0 +1,52 @@
# ruff: noqa
def get_instructions(transcript):
"""Wip."""
prompt = f"""
Audience: Coworkers.
**Do:**
- Detect the language of the transcript and provide your entire response in the same language.
- If any part of the transcript is unclear or lacks detail, politely inform the user, specifying which areas need further clarification.
- Ensure the accuracy of all information and refrain from adding unverified details.
- Format the response using proper markdown and structured sections.
- Be concise and avoid repeating yourself between the sections.
- Be super precise on nickname
- Be a nit-picker
- Auto-evaluate your response
**Don't:**
- Write something your are not sure.
- Write something that is not mention in the transcript.
- Don't make mistake while mentioning someone
**Task:**
Summarize the provided meeting transcript into clear and well-organized meeting minutes. The summary should be structured into the following sections, excluding irrelevant or inapplicable details:
1. **Summary**: Write a TL;DR of the meeting.
2. **Subjects Discussed**: List the key points or issues in bullet points.
4. **Next Steps**: Provide action items as bullet points, assigning each task to a responsible individual and including deadlines (if mentioned). Format action items as tickable checkboxes. Ensure every action is assigned and, if a deadline is provided, that it is clearly stated.
**Transcript**:
{transcript}
**Response:**
### Summary [Translate this title based on the transcripts language]
[Provide a brief overview of the key points discussed]
### Subjects Discussed [Translate this title based on the transcripts language]
- [Summarize each topic concisely]
### Next Steps [Translate this title based on the transcripts language]
- [ ] Action item [Assign to the responsible individual(s) and include a deadline if applicable, follow this strict format: Action - List of owner(s), deadline.]
"""
return [
{
"role": "system",
"content": "You are a concise and structured assistant, that summarizes meeting transcripts.",
},
{"role": "user", "content": prompt},
]
+19
View File
@@ -0,0 +1,19 @@
"""Application security."""
from fastapi import HTTPException, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from .config import SettingsDeps
security = HTTPBearer()
def verify_token(
settings: SettingsDeps,
credentials: HTTPAuthorizationCredentials = Security(security), # noqa: B008
):
"""Verify the bearer token from the Authorization header."""
token = credentials.credentials
if token != settings.app_api_token:
raise HTTPException(status_code=401, detail="Invalid token")
return token