diff --git a/base/static/css/main.css b/base/static/css/main.css index c329a18..74e4488 100644 --- a/base/static/css/main.css +++ b/base/static/css/main.css @@ -17,7 +17,7 @@ form a[role="button"] { width: 100%; } -i.owner { +i.owner, .gold { color: var(--pico-color-amber-200); } @@ -25,7 +25,7 @@ i.owner { display: none; } -a.group { +a.group, a.running { text-decoration: none; } @@ -77,11 +77,6 @@ article.message { } #hero { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; background: radial-gradient( circle 50vh at calc(100vw - 4rem) 50%, var(--pico-primary-background), @@ -90,18 +85,41 @@ article.message { color-mix(in hsl, var(--pico-primary-background) 30%, var(--pico-background-color)) 60%, color-mix(in hsl, var(--pico-primary-background) 10%, var(--pico-background-color)) 80%, var(--pico-background-color)); - display: grid; - align-items: center; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + height: 100%; + overflow-y: auto; padding: 4rem; - .big-logo { - font-size: 8rem; + main { + display: contents; } - - h1 { - font-size: 4rem; + section { + max-width: 20rem; } } +.full-page { + height: 100%; + display: grid; + grid-template-rows: 1fr; + align-items: center; + margin-bottom: 4rem; + + &.r { + -ms-grid-column-align: end; + } + .big-logo { + font-size: 8rem; + } + + h1 { + font-size: 4rem; + } +} + h1, h2, @@ -116,3 +134,91 @@ h6, i.i { margin-right: .5em; } +i.hl, .me { + color: var(--pico-primary); +} + +footer { + text-align: center; + font-weight: 350; +} + +td.c, th.c { + text-align: center; + + input { + margin: 0; + } +} + +table select { + margin-bottom: 0; +} + +.podium { + > :first-child { + font-weight: 900; + font-size: 1.25em; + &::marker { + color: var(--pico-color-amber-200); + } + } + > :nth-child(2) { + font-weight: 800; + font-size: 1.1em; + &::marker { + color: var(--pico-color-grey-300); + } + } + > :nth-child(3) { + font-weight: 600; + font-size: 1.1em; + &::marker { + color: var(--pico-color-sand-300); + } + } + .score { + margin-left: .5em; + } +} + + +.score { + font-weight: 900; + color: var(--pico-color-zinc-500); +} +.correct { + .score, i { + color: var(--pico-color-lime-200);} +} +.wrong { + .score, i { + color: var(--pico-color-red-500);} +} + +table.results, table.musics { + white-space: nowrap; +} +.sc i { + margin-right: .5em; +} + +@media (width < 576px) { + [role="group"] { + display: grid; + & > :first-child { + border-radius: var(--pico-border-radius) var(--pico-border-radius) 0 0 !important; + } + & > :not(:first-child) { + margin-left: 0 !important; + margin-top: calc(var(--pico-border-width) * -1); + } + & > :last-child { + border-radius: 0 0 var(--pico-border-radius) var(--pico-border-radius) !important; + } + } +} + +.brand-name { + color: var(--pico-primary); +} diff --git a/base/static/favicon/apple-touch-icon.png b/base/static/favicon/apple-touch-icon.png new file mode 100644 index 0000000..abb8a48 Binary files /dev/null and b/base/static/favicon/apple-touch-icon.png differ diff --git a/base/static/favicon/favicon-96x96.png b/base/static/favicon/favicon-96x96.png new file mode 100644 index 0000000..beab288 Binary files /dev/null and b/base/static/favicon/favicon-96x96.png differ diff --git a/base/static/favicon/favicon.ico b/base/static/favicon/favicon.ico new file mode 100644 index 0000000..178d60c Binary files /dev/null and b/base/static/favicon/favicon.ico differ diff --git a/base/static/favicon/favicon.svg b/base/static/favicon/favicon.svg new file mode 100644 index 0000000..63c10e0 --- /dev/null +++ b/base/static/favicon/favicon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + diff --git a/base/static/favicon/site.webmanifest b/base/static/favicon/site.webmanifest new file mode 100644 index 0000000..af6d059 --- /dev/null +++ b/base/static/favicon/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "MyWebSite", + "short_name": "MySite", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#aa40bf", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/base/static/favicon/web-app-manifest-192x192.png b/base/static/favicon/web-app-manifest-192x192.png new file mode 100644 index 0000000..f7f97da Binary files /dev/null and b/base/static/favicon/web-app-manifest-192x192.png differ diff --git a/base/static/favicon/web-app-manifest-512x512.png b/base/static/favicon/web-app-manifest-512x512.png new file mode 100644 index 0000000..9e4e381 Binary files /dev/null and b/base/static/favicon/web-app-manifest-512x512.png differ diff --git a/base/templates/auth/user_confirm_delete.html b/base/templates/auth/user_confirm_delete.html new file mode 100644 index 0000000..8840f15 --- /dev/null +++ b/base/templates/auth/user_confirm_delete.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} + +
+
+

+ {{ user.username }} +

+
+

+ Confirmer la supression du compte {{ user.username }} ? +

+

+ Toute suppression est immédiate et définitive. La suppression du compte entraîne la suppression des groupes dont celui-ci est propriétaire. +

+
+ {% csrf_token %} + + Annuler +
+
+
+{% endblock content %} diff --git a/base/templates/auth/user_form.html b/base/templates/auth/user_form.html index 257d1dc..c895f16 100644 --- a/base/templates/auth/user_form.html +++ b/base/templates/auth/user_form.html @@ -1,6 +1,24 @@ {% extends "base.html" %} {% load form %} {% block content %} -

Créer un compte

- {% form form %} - {% endblock content %} +

Créer mon compte

+ {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} +
+ {% csrf_token %} +
+ {% for field in form %} + + {% endfor %} +
+ +
+ J'ai déjà un compte +
+
+{% endblock content %} diff --git a/base/templates/auth/user_settings.html b/base/templates/auth/user_settings.html new file mode 100644 index 0000000..c69e952 --- /dev/null +++ b/base/templates/auth/user_settings.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% load form %} +{% block content %} +

+ Mon compte +

+ {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} +
+ {% csrf_token %} +
+ +
+
+ {% if not user.youtubecredentials.credentials %} + Me connecter au compte Youtube + {% else %} + + {% endif %} +
+
+ {% for field in form %} + + {% endfor %} + +
+
+ Changer mon mot de passe +
+
+ Supprimer mon compte +
+
+{% endblock content %} diff --git a/base/templates/base.html b/base/templates/base.html index 8e677eb..504228f 100644 --- a/base/templates/base.html +++ b/base/templates/base.html @@ -11,7 +11,7 @@ Musik {% endblock title %} - + {% include "favicon.html" %} @@ -68,6 +60,9 @@ {% block content %} {% endblock content %} + {% endblock body %} diff --git a/base/templates/base/inline_form.html b/base/templates/base/inline_form.html new file mode 100644 index 0000000..75769ae --- /dev/null +++ b/base/templates/base/inline_form.html @@ -0,0 +1,11 @@ +{% for error in form.non_field_errors %}
{{ error }}
{% endfor %} +
+ {% csrf_token %} +
+ {% for field in form %}{{ field }}{% endfor %} + + {% if field.errors %} + {{ field.errors|join:", " }} + {% endif %} +
+
diff --git a/base/templates/favicon.html b/base/templates/favicon.html new file mode 100644 index 0000000..dad76d7 --- /dev/null +++ b/base/templates/favicon.html @@ -0,0 +1,8 @@ +{% load static %} + + + + + + + diff --git a/base/templates/footer.html b/base/templates/footer.html new file mode 100644 index 0000000..6c57641 --- /dev/null +++ b/base/templates/footer.html @@ -0,0 +1 @@ +Musik {{ VERSION }} – © Edgar P. BurkhartMentions légales et confidentialité diff --git a/base/templates/hero.html b/base/templates/hero.html index 7dcdbe0..813c011 100644 --- a/base/templates/hero.html +++ b/base/templates/hero.html @@ -1,10 +1,27 @@ {% load static %}
-
- -

Musik

-

- Jouer -

-
+
+
+
+ +

Musik

+

+ Jouer +

+
+
+
+
+

+ Musik Le jeu où ta playlist devient ton arme secrète ! +

+

+ Invite ta bande, ajoute tes sons fétiches, et c’est parti ! Une playlist Youtube apparaît, mélangeant les coups de cœur de tout le monde. Le jeu ? Écoute, devine qui a choisi quoi, et découvre les secrets musicaux de tes potes. Entre pièges, révélations et fous rires, Musik c’est le jeu parfait pour tester vos oreilles… et vos amitiés. Prêt à jouer le DJ incognito ? +

+
+
+
+
diff --git a/base/templates/index.html b/base/templates/index.html index 5235a9f..e026c32 100644 --- a/base/templates/index.html +++ b/base/templates/index.html @@ -1,11 +1,4 @@ {% extends "base.html" %} -{% block content %} - {% include "game/home.html" %} -{% endblock content %} {% block body %} - {% if user.is_authenticated %} - {{ block.super }} - {% else %} - {% include "hero.html" %} - {% endif %} + {% include "hero.html" %} {% endblock body %} diff --git a/base/templates/privacy.html b/base/templates/privacy.html new file mode 100644 index 0000000..1549403 --- /dev/null +++ b/base/templates/privacy.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block content %} +

Mentions légales

+

Éditeur

+

+ Ce site est réalisé et hébergé par Edgar P. Burkhart. +

+

Données Personnelles

+

+ Dans le cadre de la création d'un compte, un nom d'utilisateur, une adresse email et un mot de passe sont requis. + Ces données sont stockées dans le seul but de permettre la connexion et la réinitialisation du mot de passe de l'utilisateur (dans le cas de l'adresse mail). +

+

+ La connexion à un compte Youtube est utilisée pour permettre la génération automatique de playlists, et sa suppression le cas échéant. + Elle n'est pas requise pour l'utilisation de Musik. + Youtube est une marque de Google LLC. +

+

+ Les données saisies dans Musik (groupes créés, listes de musiques) sont conservées jusqu'à demande de suppression. La suppression du compte entraîne la suppression de l'ensemble des données qui y sont liées. + La suppression du compte peut être demandée dans les paramètres du compte. La suppression des données est immédiate et définitive. +

+{% endblock content %} diff --git a/base/templates/registration/login.html b/base/templates/registration/login.html index 1a78811..4e0394e 100644 --- a/base/templates/registration/login.html +++ b/base/templates/registration/login.html @@ -1,9 +1,30 @@ {% extends "base.html" %} -{% load form %} {% block content %}

Connexion

-

- Créer un compte -

- {% form form submit="Se connecter" %} - {% endblock content %} + {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} +
+ {% csrf_token %} + {% for field in form %} + {% if field.id_for_label %} + + {% endfor %} + +
+ Créer mon compte +
+
+ J'ai oublié mon mot de passe +
+
+{% endblock content %} diff --git a/base/templates/registration/password_change_form.html b/base/templates/registration/password_change_form.html new file mode 100644 index 0000000..25304fe --- /dev/null +++ b/base/templates/registration/password_change_form.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% load form %} +{% block content %} +

+ Changer mon mot de passe +

+ {% form form submit="Changer mon mot de passe" %} + {% endblock content %} diff --git a/base/templates/registration/password_reset_confirm.html b/base/templates/registration/password_reset_confirm.html new file mode 100644 index 0000000..2f7f317 --- /dev/null +++ b/base/templates/registration/password_reset_confirm.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% load form %} +{% block content %} +

+ Réinitialiser mon mot de passe +

+ {% form form submit="Réinitialiser mon mot de passe" %} + {% endblock content %} diff --git a/base/templates/registration/password_reset_form.html b/base/templates/registration/password_reset_form.html new file mode 100644 index 0000000..2f7f317 --- /dev/null +++ b/base/templates/registration/password_reset_form.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% load form %} +{% block content %} +

+ Réinitialiser mon mot de passe +

+ {% form form submit="Réinitialiser mon mot de passe" %} + {% endblock content %} diff --git a/base/templatetags/form.py b/base/templatetags/form.py index 9ab40ce..66532a8 100644 --- a/base/templatetags/form.py +++ b/base/templatetags/form.py @@ -4,8 +4,13 @@ register = template.Library() @register.inclusion_tag("base/form.html") -def form(form, **kwargs): +def form(f, **kwargs): return kwargs | { - "form": form, - "errors": form.errors, + "form": f, + "errors": f.errors, } + + +@register.inclusion_tag("base/inline_form.html") +def inline_form(f, **kwargs): + return form(f, **kwargs) diff --git a/base/urls.py b/base/urls.py index 2a6b2d5..f240d91 100644 --- a/base/urls.py +++ b/base/urls.py @@ -1,9 +1,30 @@ -from django.urls import include, path +from django.contrib.auth import views as auth_views +from django.urls import path +from django.views.generic import TemplateView from . import views urlpatterns = [ path("", views.HomePageView.as_view(), name="index"), path("accounts/signup/", views.SignupView.as_view(), name="signup"), - path("accounts/", include("django.contrib.auth.urls")), + path("accounts/login/", auth_views.LoginView.as_view(), name="login"), + path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"), + path( + "accounts/password_change/", + views.PasswordChangeView.as_view(), + name="password_change", + ), + path( + "accounts/password_reset/", + views.PasswordResetView.as_view(), + name="password_reset", + ), + path( + "accounts/reset///", + views.PasswordResetConfirmView.as_view(), + name="password_reset_confirm", + ), + path("accounts/settings/", views.AccountView.as_view(), name="account_settings"), + path("accounts/delete/", views.AccountDeleteView.as_view(), name="account_delete"), + path("legal/", TemplateView.as_view(template_name="privacy.html"), name="legal"), ] diff --git a/base/views.py b/base/views.py index 29eb8b3..7ad30e5 100644 --- a/base/views.py +++ b/base/views.py @@ -1,8 +1,10 @@ +from django.contrib.auth import views as auth_views from django.contrib.auth.models import User from django.contrib.messages.views import SuccessMessageMixin +from django.shortcuts import redirect from django.urls import reverse_lazy from django.views.generic.base import TemplateView -from django.views.generic.edit import CreateView +from django.views.generic.edit import CreateView, DeleteView, UpdateView from . import forms @@ -10,9 +12,53 @@ from . import forms class HomePageView(TemplateView): template_name = "index.html" + def dispatch(self, request, *args, **kwargs): + if request.user.is_authenticated: + return redirect("home") + return super().dispatch(request, *args, **kwargs) + class SignupView(SuccessMessageMixin, CreateView): model = User form_class = forms.UserSignupForm success_url = reverse_lazy("login") success_message = "Le compte %(username)s a été créé avec succès." + + +class PasswordChangeView(SuccessMessageMixin, auth_views.PasswordChangeView): + success_message = "Le mot de passe a été changé avec succès." + success_url = reverse_lazy("index") + + +class PasswordResetView(SuccessMessageMixin, auth_views.PasswordResetView): + success_message = "Un courriel a été envoyé avec les instructions pour réinitialiser votre mot de passe." + success_url = reverse_lazy("login") + + +class PasswordResetConfirmView( + SuccessMessageMixin, auth_views.PasswordResetConfirmView +): + success_message = "Le mot de passe a été réinitialisé avec succès." + success_url = reverse_lazy("login") + + +class AccountView(UpdateView): + model = User + fields = ["username", "email"] + success_url = reverse_lazy("index") + template_name = "auth/user_settings.html" + + def get_object(self, queryset=None): + if queryset is None: + queryset = self.get_queryset() + return queryset.get(pk=self.request.user.pk) + + +class AccountDeleteView(DeleteView): + model = User + success_url = reverse_lazy("index") + + def get_object(self, queryset=None): + if queryset is None: + queryset = self.get_queryset() + return queryset.get(pk=self.request.user.pk) diff --git a/compose.yaml b/compose.yaml index 04838ec..a6ee8b3 100644 --- a/compose.yaml +++ b/compose.yaml @@ -32,10 +32,12 @@ services: rabbitmq: image: rabbitmq container_name: musik_rabbitmq + restart: unless-stopped postgres: image: postgres:17 container_name: musik_postgres + restart: unless-stopped env_file: stack.env volumes: - /docker/musik/postgres:/var/lib/postgresql/data diff --git a/game/forms.py b/game/forms.py index 99a18cf..a548455 100644 --- a/game/forms.py +++ b/game/forms.py @@ -3,6 +3,16 @@ from django import forms from . import models +class GroupForm(forms.ModelForm): + class Meta: + model = models.Group + fields = ["name"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["name"].widget.attrs["placeholder"] = self.fields["name"].label + + class GroupAddMembersForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -29,3 +39,23 @@ class MusikGameForm(forms.ModelForm): kwargs["initial"].setdefault("players", players) super().__init__(*args, **kwargs) self.fields["players"].queryset = players + + +class AnswerForm(forms.Form): + def __init__(self, *args, **kwargs): + game = kwargs.pop("game") + user = kwargs.pop("user") + super().__init__(*args, **kwargs) + + for music in game.musicgameorder_set.all(): + self.fields[f"answer-{music.order}"] = forms.ChoiceField( + choices=[("", "")] + + list(game.players.all().values_list("id", "username")), + required=False, + label=music.order, + initial=ma.answer.id + if (ma := music.musicgameanswer_set.filter(player=user).first()) + and ma.answer + else "", + disabled=game.over, + ) diff --git a/game/migrations/0015_groupleader.py b/game/migrations/0015_groupleader.py new file mode 100644 index 0000000..a9e5dc2 --- /dev/null +++ b/game/migrations/0015_groupleader.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.3 on 2025-06-14 18:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("game", "0014_musikgame_playlist_loading"), + ] + + operations = [ + migrations.CreateModel( + name="GroupLeader", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("is_leader", models.BooleanField(default=False)), + ( + "member", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to="game.group_members", + ), + ), + ], + ), + ] diff --git a/game/migrations/0016_alter_groupleader_member.py b/game/migrations/0016_alter_groupleader_member.py new file mode 100644 index 0000000..1561a34 --- /dev/null +++ b/game/migrations/0016_alter_groupleader_member.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.3 on 2025-06-14 18:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("game", "0015_groupleader"), + ] + + operations = [ + migrations.AlterField( + model_name="groupleader", + name="member", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="lead", + to="game.group_members", + ), + ), + ] diff --git a/game/migrations/0017_youtubecredentials_title.py b/game/migrations/0017_youtubecredentials_title.py new file mode 100644 index 0000000..89870e4 --- /dev/null +++ b/game/migrations/0017_youtubecredentials_title.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.3 on 2025-06-15 08:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("game", "0016_alter_groupleader_member"), + ] + + operations = [ + migrations.AddField( + model_name="youtubecredentials", + name="title", + field=models.CharField(blank=True), + ), + ] diff --git a/game/migrations/0018_musicgameanswer.py b/game/migrations/0018_musicgameanswer.py new file mode 100644 index 0000000..1f760be --- /dev/null +++ b/game/migrations/0018_musicgameanswer.py @@ -0,0 +1,60 @@ +# Generated by Django 5.2.3 on 2025-06-15 10:36 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("game", "0017_youtubecredentials_title"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="MusicGameAnswer", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "answer", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "game", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="game.musicgameorder", + ), + ), + ( + "player", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["game"], + "constraints": [ + models.UniqueConstraint( + fields=("game", "player"), name="unique_answer" + ) + ], + }, + ), + ] diff --git a/game/migrations/0019_alter_musikgame_options_musikgame_over.py b/game/migrations/0019_alter_musikgame_options_musikgame_over.py new file mode 100644 index 0000000..f5e4489 --- /dev/null +++ b/game/migrations/0019_alter_musikgame_options_musikgame_over.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.3 on 2025-06-15 10:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("game", "0018_musicgameanswer"), + ] + + operations = [ + migrations.AlterModelOptions( + name="musikgame", + options={"ordering": ["-over", "-date"]}, + ), + migrations.AddField( + model_name="musikgame", + name="over", + field=models.BooleanField(default=False), + ), + ] diff --git a/game/migrations/0020_alter_musikgame_options_musicgameresults.py b/game/migrations/0020_alter_musikgame_options_musicgameresults.py new file mode 100644 index 0000000..3af5916 --- /dev/null +++ b/game/migrations/0020_alter_musikgame_options_musicgameresults.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.3 on 2025-06-15 11:23 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("game", "0019_alter_musikgame_options_musikgame_over"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name="musikgame", + options={"ordering": ["over", "-date"]}, + ), + migrations.CreateModel( + name="MusicGameResults", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("score", models.PositiveIntegerField(default=0)), + ( + "game", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="game.musikgame" + ), + ), + ( + "player", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-score"], + "constraints": [ + models.UniqueConstraint( + fields=("game", "player"), name="unique_result" + ) + ], + }, + ), + ] diff --git a/game/migrations/0021_alter_musicgameresults_score.py b/game/migrations/0021_alter_musicgameresults_score.py new file mode 100644 index 0000000..3166d4d --- /dev/null +++ b/game/migrations/0021_alter_musicgameresults_score.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.3 on 2025-06-15 11:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("game", "0020_alter_musikgame_options_musicgameresults"), + ] + + operations = [ + migrations.AlterField( + model_name="musicgameresults", + name="score", + field=models.IntegerField(default=0), + ), + ] diff --git a/game/migrations/0022_musicgameorder_value.py b/game/migrations/0022_musicgameorder_value.py new file mode 100644 index 0000000..712353c --- /dev/null +++ b/game/migrations/0022_musicgameorder_value.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.3 on 2025-06-15 12:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("game", "0021_alter_musicgameresults_score"), + ] + + operations = [ + migrations.AddField( + model_name="musicgameorder", + name="value", + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/game/migrations/0023_alter_musicvideo_options.py b/game/migrations/0023_alter_musicvideo_options.py new file mode 100644 index 0000000..ee96adb --- /dev/null +++ b/game/migrations/0023_alter_musicvideo_options.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.3 on 2025-06-15 14:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("game", "0022_musicgameorder_value"), + ] + + operations = [ + migrations.AlterModelOptions( + name="musicvideo", + options={"ordering": ["blacklisted", "-date_added"]}, + ), + ] diff --git a/game/models.py b/game/models.py index 88fe6cc..153583f 100644 --- a/game/models.py +++ b/game/models.py @@ -1,5 +1,6 @@ from django.contrib.auth.models import User from django.db import models +from django.db.models import F from django.db.models.functions import Lower from django.db.models.signals import post_delete, post_save from django.dispatch import receiver @@ -11,6 +12,7 @@ from . import tasks class YoutubeCredentials(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) credentials = models.JSONField() + title = models.CharField(blank=True) class Group(models.Model): @@ -23,12 +25,33 @@ class Group(models.Model): def get_absolute_url(self): return reverse("group_detail", kwargs={"pk": self.pk}) + def is_owner(self, user): + return user == self.owner + + def is_leader(self, user): + return ( + self.is_owner(user) + or self.members.through.objects.filter( + lead__is_leader=True, user=user + ).exists() + ) + + def is_member(self, user): + return self.is_owner(user) or self.members.filter(user=user).exists() + class Meta: constraints = [ models.UniqueConstraint(Lower("name"), "owner", name="unique_group_name") ] +class GroupLeader(models.Model): + member = models.OneToOneField( + Group.members.through, on_delete=models.CASCADE, related_name="lead" + ) + is_leader = models.BooleanField(default=False) + + class MusicVideo(models.Model): yt_id = models.CharField(max_length=16) title = models.CharField(blank=True) @@ -43,6 +66,12 @@ class MusicVideo(models.Model): fields=("yt_id", "owner", "group"), name="unique_music_in_group" ) ] + ordering = ["blacklisted", "-date_added"] + + +class GameManager(models.Manager): + def playing(self): + return self.filter(over=False) class MusikGame(models.Model): @@ -52,10 +81,16 @@ class MusikGame(models.Model): players = models.ManyToManyField(User, verbose_name="Joueurs") playlist = models.CharField(blank=True, verbose_name="Playlist YouTube") playlist_loading = models.BooleanField(default=False) + over = models.BooleanField(default=False) + + objects = GameManager() def get_absolute_url(self): return reverse("game_detail", kwargs={"pk": self.pk}) + class Meta: + ordering = ["over", "-date"] + @receiver(post_save, sender=MusikGame) def generateYoutubePlaylist(sender, instance, created, **kwargs): @@ -64,6 +99,9 @@ def generateYoutubePlaylist(sender, instance, created, **kwargs): if creds := instance.group.owner.youtubecredentials: tasks.generate_playlist.delay_on_commit(creds.credentials, instance.pk) + else: + instance.playlist_loading = False + instance.save() @receiver(post_delete, sender=MusikGame) @@ -80,6 +118,14 @@ class MusicGameOrder(models.Model): player = models.ForeignKey(User, on_delete=models.CASCADE) music_video = models.ForeignKey(MusicVideo, on_delete=models.CASCADE) order = models.PositiveIntegerField() + value = models.PositiveIntegerField(default=0) + + def update_value(self): + x = self.musicgameanswer_set.filter(game__player=F("answer")).count() + n = self.game.players.count() + n = max(3, n) + self.value = 1000 * 2 ** (-(x - 2) / (n - 2)) + self.save() class Meta: constraints = [ @@ -89,3 +135,44 @@ class MusicGameOrder(models.Model): models.UniqueConstraint(fields=("game", "order"), name="unique_order"), ] ordering = ["order"] + + +class AnswerManager(models.Manager): + def score(self, game, player): + qs = self.filter(game__game=game, player=player) + return ( + qs.exclude(game__player=player) + .filter(game__player=F("answer")) + .aggregate(score=models.Sum("game__value", default=0)) + .get("score") + - 500 + * qs.filter(game__player=player).exclude(game__player=F("answer")).count() + ) + + +class MusicGameAnswer(models.Model): + game = models.ForeignKey(MusicGameOrder, on_delete=models.CASCADE) + player = models.ForeignKey(User, on_delete=models.CASCADE) + answer = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, related_name="+" + ) + + objects = AnswerManager() + + class Meta: + constraints = [ + models.UniqueConstraint(fields=("game", "player"), name="unique_answer"), + ] + ordering = ["game"] + + +class MusicGameResults(models.Model): + game = models.ForeignKey(MusikGame, on_delete=models.CASCADE) + player = models.ForeignKey(User, on_delete=models.CASCADE) + score = models.IntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=("game", "player"), name="unique_result") + ] + ordering = ["-score"] diff --git a/game/tasks.py b/game/tasks.py index 22a7dc0..61937d0 100644 --- a/game/tasks.py +++ b/game/tasks.py @@ -18,7 +18,7 @@ def generate_playlist(creds, game_pk): "description": "Playlist générée par Musik", }, "status": { - "privacyStatus": "private", + "privacyStatus": "unlisted", }, }, ) diff --git a/game/templates/game/group_confirm_delete.html b/game/templates/game/group_confirm_delete.html index f8d7e5e..26c5411 100644 --- a/game/templates/game/group_confirm_delete.html +++ b/game/templates/game/group_confirm_delete.html @@ -1,5 +1,4 @@ {% extends "base.html" %} -{% load form %} {% block content %}
diff --git a/game/templates/game/group_detail.html b/game/templates/game/group_detail.html index b6f48d8..90e9901 100644 --- a/game/templates/game/group_detail.html +++ b/game/templates/game/group_detail.html @@ -9,200 +9,8 @@ {% endif %} {{ group.name }} - {% if group.owner == user %} -

-

- {% csrf_token %} -

- Jouer -

-
- Renommer - - - Supprimer -
-
-

- {% endif %} - {% if group.musikgame_set.exists %} -

- Parties -

-
- {% csrf_token %} - - - {% if group.owner == user %}{% endif %} - - - - - - {% for game in group.musikgame_set.all %} - - {% if group.owner == user %} - - {% endif %} - - - - - {% endfor %} - -
Date - Playlists - Joueurs
- - - {{ game.date }} - - {% if game.playlist %} - Playlist - {% endif %} - {{ game.players.all|join:", " }}
- {% if group.owner == user %} - - {% endif %} -
- {% endif %} -

- Membres -

-
- {% csrf_token %} - - - - {% if group.owner == user %}{% endif %} - - - - - - - - {% if group.owner == user %}{% endif %} - - - - - {% for member in members.all %} - - {% if group.owner == user %} - - {% endif %} - - - - - {% endfor %} - -
Membre - - - -
{{ group.owner }} - - {{ owner_count }}
- - {{ member }}{{ member.count }}
- {% if group.owner == user %} - - {% endif %} -
- {% if group.owner == user %} -
- {% csrf_token %} -
- - -
-
- {% endif %} -

- Mes musiques {{ musics.count }} -

-
- - Liste des musiques - -
- {% csrf_token %} - - - - - - - - - - - {% for music in musics %} - - - - - - - {% empty %} - - - - {% endfor %} - -
MusiqueID - -
- - {{ music.title }} - {{ music.yt_id }} - - -
Aucune musique.
- {% if musics %} -
- - -
- {% endif %} -
-
-
- {% csrf_token %} -
- - -
-
+ {% include "game/include/group_buttons.html" %} + {% include "game/include/group_games.html" %} + {% include "game/include/group_members.html" %} + {% include "game/include/group_musics.html" %} {% endblock content %} diff --git a/game/templates/game/home.html b/game/templates/game/home.html index 9aee9fe..b1d2399 100644 --- a/game/templates/game/home.html +++ b/game/templates/game/home.html @@ -1,26 +1,30 @@ -

Bienvenue {{ user.username }} !

-

- Mes groupes -

-

- Créer un groupe - {% if not user.youtubecredentials.credentials %} - Me connecter au compte Youtube +{% extends "base.html" %} +{% load form %} +{% block content %} +

+ Musik +

+

+ Bienvenue {{ user.username }} ! +

+

+ Mes groupes +

+ {% if user.owned_group_set.exists or user.group_set.exists %} + {% for group in user.owned_group_set.all %} + +
+ {{ group.name }} +
+
+ {% endfor %} + {% for group in user.group_set.all %} + +
+ {{ group.name }} {{ group.owner }} +
+
+ {% endfor %} {% endif %} -

-{% if user.owned_group_set.exists or user.group_set.exists %} - {% for group in user.owned_group_set.all %} - -
- {{ group.name }} -
-
- {% endfor %} - {% for group in user.group_set.all %} - -
- {{ group.name }} {{ group.owner }} -
-
- {% endfor %} -{% endif %} + {% inline_form group_form action="group_create" submit="Créer" %} +{% endblock content %} diff --git a/game/templates/game/include/game_results.html b/game/templates/game/include/game_results.html new file mode 100644 index 0000000..ca2821c --- /dev/null +++ b/game/templates/game/include/game_results.html @@ -0,0 +1,49 @@ +{% load game %} +{% if musikgame.over %} +

+ Résultats +

+
+ + Résultats + +
+ + + + + + + {% for player in musikgame.musicgameresults_set.all %} + + + {% endfor %} + + + + {% for music in musikgame.musicgameorder_set.all %} + + + + + {% for player in musikgame.musicgameresults_set.all %} + {% answer player music %} + {% endfor %} + + {% endfor %} + +
+ + + Musique + + Joueur + {{ player.player.username }} + {% if forloop.first %}{% endif %} + {{ player.score }} +
{{ music.order }} + {{ music.music_video.title }} + {{ music.player }}
+
+
+{% endif %} diff --git a/game/templates/game/include/group_buttons.html b/game/templates/game/include/group_buttons.html new file mode 100644 index 0000000..065545f --- /dev/null +++ b/game/templates/game/include/group_buttons.html @@ -0,0 +1,28 @@ +{% if is_leader %} +

+

+ {% csrf_token %} +

+ Jouer +

+
+ {% if is_owner %} + Renommer + {% endif %} + + {% if is_owner %} + + Supprimer + {% endif %} +
+
+

+{% endif %} diff --git a/game/templates/game/include/group_games.html b/game/templates/game/include/group_games.html new file mode 100644 index 0000000..15016fb --- /dev/null +++ b/game/templates/game/include/group_games.html @@ -0,0 +1,62 @@ +{% load form youtube %} +{% if group.musikgame_set.exists %} + {% for game in group.musikgame_set.playing %} + +
{{ game.date }}
+
+ {% endfor %} +

+ Parties +

+
+ {% csrf_token %} + + + {% if group.owner == user %}{% endif %} + + + + + + + {% for game in group.musikgame_set.all %} + + {% if group.owner == user %} + + {% endif %} + + + + + + {% endfor %} + +
+ + Date + Playlists + Joueurs
+ + + {% if game.over %} + + {% else %} + + {% endif %} + + {{ game.date }} + + {% if game.playlist %} + Playlist + {% endif %} + {{ game.players.all|join:", " }}
+ {% if group.owner == user %} + + {% endif %} +
+{% endif %} diff --git a/game/templates/game/include/group_members.html b/game/templates/game/include/group_members.html new file mode 100644 index 0000000..daf3059 --- /dev/null +++ b/game/templates/game/include/group_members.html @@ -0,0 +1,81 @@ +

+ Membres +

+
+ {% csrf_token %} + + + + {% if is_leader %}{% endif %} + + + + + + + + {% if is_leader %}{% endif %} + + + + + {% for member in members.all %} + + {% if is_leader %} + + {% endif %} + + + + + {% endfor %} + +
Membre + + + +
{{ group.owner }} + + {{ owner_count }}
+ + {{ member.user }} + {% if is_owner %} + + {% elif member.lead.is_leader %} + + {% endif %} + {{ member.count }}
+ {% if is_leader %} +
+ {% if is_owner %} + + {% endif %} + +
+ {% endif %} +
+{% if is_leader %} +
+ {% csrf_token %} +
+ + +
+
+{% endif %} diff --git a/game/templates/game/include/group_musics.html b/game/templates/game/include/group_musics.html new file mode 100644 index 0000000..56117cf --- /dev/null +++ b/game/templates/game/include/group_musics.html @@ -0,0 +1,66 @@ +

+ Mes musiques {{ musics.count }} +

+
+ + Liste des musiques + +
+ {% csrf_token %} +
+ + + + + + + + + + + {% for music in musics %} + + + + + + + {% empty %} + + + + {% endfor %} + +
MusiqueID + +
+ + {{ music.title }} + {{ music.yt_id }} + + +
Aucune musique.
+
+ {% if musics %} +
+ + +
+ {% endif %} +
+
+
+ {% csrf_token %} +
+ + +
+
diff --git a/game/templates/game/musikgame_answer.html b/game/templates/game/musikgame_answer.html new file mode 100644 index 0000000..f947802 --- /dev/null +++ b/game/templates/game/musikgame_answer.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block content %} +

Mes réponses

+
+ {% csrf_token %} + + + + + + + + + {% for field in form %} + + + + + {% endfor %} + +
+ + Réponse
{{ field.label }}{{ field }}
+ {% if not musikgame.over %}{% endif %} +
+{% endblock content %} diff --git a/game/templates/game/musikgame_detail.html b/game/templates/game/musikgame_detail.html index 444c7f1..66708c0 100644 --- a/game/templates/game/musikgame_detail.html +++ b/game/templates/game/musikgame_detail.html @@ -2,20 +2,52 @@ {% load youtube %} {% block content %}

- {{ musikgame.date }} + {% if musikgame.over %} + + {% else %} + + {% endif %} + {{ musikgame.date }}

- {% if musikgame.playlist or musikgame.playlist_loading %} -

- Playlist -

- {% endif %} +
+ {% csrf_token %} +
+ {% if musikgame.playlist or musikgame.playlist_loading %} + Playlist + {% endif %} + {% if musikgame.over %} + Mes réponses + {% else %} + Répondre + {% endif %} +
+ {% if is_leader and not musikgame.over %} +
+ +
+ {% endif %} +

Joueurs

-

{{ musikgame.players.all|join:", " }}

+ {% if musikgame.over %} +
    + {% for player in musikgame.musicgameresults_set.all %} +
  1. + {{ player.player.username }} {{ player.score }} +
  2. + {% endfor %} +
+ {% else %} +

{{ musikgame.players.all|join:", " }}

+ {% endif %}

Musiques

@@ -31,38 +63,5 @@ {% endfor %} -

- Résultats -

-
- - Résultats - - - - - - - - - - - {% for music in musikgame.musicgameorder_set.all %} - - - - - - {% endfor %} - -
- - - Musique - - Joueur -
{{ music.order }} - {{ music.music_video.title }} - {{ music.player }}
-
+ {% include "game/include/game_results.html" %} {% endblock content %} diff --git a/game/templates/game/musikgame_form.html b/game/templates/game/musikgame_form.html index 3e6d4ed..655723a 100644 --- a/game/templates/game/musikgame_form.html +++ b/game/templates/game/musikgame_form.html @@ -4,5 +4,14 @@

{{ group.name }}

+

+ {% if group.owner.youtubecredentials.credentials %} + Une playlist sera générée automatiquement sur le compte Youtube de {{ group.owner }} ({{ group.owner.youtubecredentials.title }}). + {% elif user == group.owner %} + Connecter mon compte Youtube + {% else %} + Aucune playlist Youtube ne sera générée car {{ group.owner }} n'a pas lié son compte Youtube. + {% endif %} +

{% form form %} {% endblock content %} diff --git a/game/templates/tags/game/answer.html b/game/templates/tags/game/answer.html new file mode 100644 index 0000000..bfdeba5 --- /dev/null +++ b/game/templates/tags/game/answer.html @@ -0,0 +1,16 @@ +{% if empty %} + + + {{ score }} + +{% elif correct %} + {{ answer }} + + {{ score }} + +{% else %} + {{ answer }} + + {{ score }} + +{% endif %} diff --git a/game/templatetags/game.py b/game/templatetags/game.py new file mode 100644 index 0000000..eec1bd0 --- /dev/null +++ b/game/templatetags/game.py @@ -0,0 +1,30 @@ +from django import template + +from .. import models + +register = template.Library() + + +@register.inclusion_tag("tags/game/answer.html") +def answer(player, music): + res = { + "answer": "", + "correct": False, + "score": 0, + "empty": False, + } + + answer = models.MusicGameAnswer.objects.filter( + player=player.player, game=music + ).first() + if answer: + res["answer"] = answer.answer + res["correct"] = answer.answer == music.player + if music.player == player.player: + res["score"] = 0 if res["correct"] else "−500" + else: + res["score"] = music.value if res["correct"] else 0 + else: + res["empty"] = True + + return res diff --git a/game/urls.py b/game/urls.py index 763d277..927dc79 100644 --- a/game/urls.py +++ b/game/urls.py @@ -3,6 +3,7 @@ from django.urls import path from . import views urlpatterns = [ + path("home", views.HomeView.as_view(), name="home"), path("group/create/", views.GroupCreateView.as_view(), name="group_create"), path( "group//update/", views.GroupUpdateView.as_view(), name="group_update" @@ -41,11 +42,17 @@ urlpatterns = [ views.GroupRemoveMemberView.as_view(), name="group_remove_member", ), + path( + "group//set_leader/", + views.GroupSetLead.as_view(), + name="group_set_leader", + ), path( "group//start_game/", views.GameCreateView.as_view(), name="start_game" ), path("group/game//", views.GameDetailView.as_view(), name="game_detail"), path("youtube_login/", views.YoutubeLoginView.as_view(), name="youtube_login"), + path("youtube_logout/", views.YoutubeLogoutView.as_view(), name="youtube_logout"), path( "youtube_callback/", views.YoutubeCallbackView.as_view(), @@ -56,4 +63,10 @@ urlpatterns = [ views.GroupClearBlacklistView.as_view(), name="group_clear_blacklist", ), + path( + "group/game//answer/", + views.GameAnswerView.as_view(), + name="game_answer", + ), + path("group/game//end/", views.GameEndView.as_view(), name="game_end"), ] diff --git a/game/views.py b/game/views.py index f6632f0..45685aa 100644 --- a/game/views.py +++ b/game/views.py @@ -1,21 +1,33 @@ import random import google_auth_oauthlib +import googleapiclient from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User from django.contrib.messages.views import SuccessMessageMixin +from django.core.exceptions import PermissionDenied from django.db import IntegrityError from django.db.models import Count, Q from django.shortcuts import get_object_or_404, redirect from django.views import View +from django.views.generic import TemplateView from django.views.generic.detail import DetailView, SingleObjectMixin from django.views.generic.edit import CreateView, DeleteView, UpdateView from . import forms, models, utils +class HomeView(LoginRequiredMixin, TemplateView): + template_name = "game/home.html" + + def get_context_data(self, **kwargs): + data = super().get_context_data(**kwargs) + data["group_form"] = forms.GroupForm() + return data + + class OwnerFilterMixin(LoginRequiredMixin): def get_queryset(self): return super().get_queryset().filter(owner=self.request.user) @@ -70,14 +82,19 @@ class GroupDetailView(MemberFilterMixin, GroupMixin, DetailView): .musicvideo_set.filter(owner=data["group"].owner, blacklisted=False) .count() ) - data["members"] = data["group"].members.annotate( + data["members"] = models.Group.members.through.objects.filter( + group=data["group"] + ).annotate( count=Count( - "musicvideo", + "user__musicvideo", filter=Q( - musicvideo__group=data["group"], musicvideo__blacklisted=False + user__musicvideo__group=data["group"], + user__musicvideo__blacklisted=False, ), ) ) + data["is_leader"] = data["group"].is_leader(self.request.user) + data["is_owner"] = data["group"].is_owner(self.request.user) return data @@ -87,49 +104,59 @@ class GroupAddMusicView(MemberFilterMixin, SingleObjectMixin, View): def post(self, request, pk): group = self.get_object() - yt_id = request.POST.get("yt_id") - if not yt_id: - messages.add_message(request, messages.ERROR, "Aucun identifiant donné") - return redirect(group) - yt_id = utils.parse_musik(yt_id) + ids = request.POST.get("yt_id") + for yt_id in ids.split(): + if not yt_id: + messages.add_message(request, messages.ERROR, "Aucun identifiant donné") + return redirect(group) + yt_id = utils.parse_musik(yt_id) - title = utils.get_yt_title(yt_id) - if not title: - messages.add_message( - request, messages.ERROR, f"Vidéo Youtube invalide : {yt_id}" - ) - return redirect(group) - try: - group.musicvideo_set.create(yt_id=yt_id, title=title, owner=request.user) - except IntegrityError: - messages.add_message( - request, messages.ERROR, f"Vidéo Youtube déjà ajoutée : {yt_id}" - ) + title = utils.get_yt_title(yt_id) + if not title: + messages.add_message( + request, messages.ERROR, f"Vidéo Youtube invalide : {yt_id}" + ) + else: + try: + group.musicvideo_set.create( + yt_id=yt_id, title=title, owner=request.user + ) + except IntegrityError: + messages.add_message( + request, messages.ERROR, f"Vidéo Youtube déjà ajoutée : {yt_id}" + ) - messages.add_message( - request, messages.SUCCESS, f"Vidéo Youtube ajoutée : {yt_id}" - ) + messages.add_message( + request, messages.SUCCESS, f"Vidéo Youtube ajoutée : {yt_id}" + ) return redirect(group) -class GroupAddMemberView(OwnerFilterMixin, SingleObjectMixin, View): +class GroupAddMemberView(MemberFilterMixin, SingleObjectMixin, View): model = models.Group def post(self, request, pk): group = self.get_object() - username = request.POST.get("username") - user = User.objects.get(username=username) - if user == group.owner: - messages.add_message( - request, messages.WARNING, f"{user} est le propriétaire du groupe." - ) - return redirect(group) - if user in group.members.all(): - messages.add_message( - request, messages.WARNING, f"{user} est déjà membre du groupe." - ) + if not group.is_leader(request.user): + raise PermissionDenied() + usernames = request.POST.get("username") + for username in usernames.split(): + user = User.objects.filter(username=username).first() + if not user: + messages.add_message( + request, messages.ERROR, f"{username} n'existe pas." + ) + elif user == group.owner: + messages.add_message( + request, messages.WARNING, f"{user} est le propriétaire du groupe." + ) + elif user in group.members.all(): + messages.add_message( + request, messages.WARNING, f"{user} est déjà membre du groupe." + ) + else: + group.members.add(user) - group.members.add(user) return redirect(group) @@ -180,14 +207,16 @@ class GroupUnblacklistMusicView(MemberFilterMixin, SingleObjectMixin, View): return redirect(group) -class GroupRemoveMemberView(OwnerFilterMixin, SingleObjectMixin, View): +class GroupRemoveMemberView(MemberFilterMixin, SingleObjectMixin, View): model = models.Group def post(self, request, pk): group = self.get_object() + if not group.is_leader(request.user): + raise PermissionDenied() relations = models.Group.members.through.objects.filter( - group=group, user__id__in=request.POST.getlist("member") + group=group, pk__in=request.POST.getlist("member") ) if relations.count() == 0: messages.add_message(request, messages.INFO, "Aucun membre supprimé.") @@ -210,6 +239,25 @@ class GroupRemoveMemberView(OwnerFilterMixin, SingleObjectMixin, View): return redirect(group) +class GroupSetLead(OwnerFilterMixin, SingleObjectMixin, View): + model = models.Group + + def post(self, request, pk): + group = self.get_object() + + members = models.Group.members.through.objects.filter(group=group) + for member in members.filter(pk__in=request.POST.getlist("leader")): + models.GroupLeader.objects.update_or_create( + member=member, defaults={"is_leader": True} + ) + for member in members.exclude(pk__in=request.POST.getlist("leader")): + models.GroupLeader.objects.update_or_create( + member=member, defaults={"is_leader": False} + ) + + return redirect(group) + + class GroupRemoveGameView(OwnerFilterMixin, SingleObjectMixin, View): model = models.Group @@ -250,15 +298,15 @@ class GameCreateView(LoginRequiredMixin, CreateView): def get_context_data(self, **kwargs): data = super().get_context_data(**kwargs) - data["group"] = get_object_or_404( - models.Group, owner=self.request.user, pk=self.kwargs["pk"] - ) + data["group"] = get_object_or_404(models.Group, pk=self.kwargs["pk"]) + if not data["group"].is_leader(self.request.user): + raise PermissionDenied() return data def form_valid(self, form): - group = get_object_or_404( - models.Group, owner=self.request.user, pk=self.kwargs["pk"] - ) + group = get_object_or_404(models.Group, pk=self.kwargs["pk"]) + if not group.is_leader(self.request.user): + return super().form_invalid(form) form.instance.group = group res = super().form_valid(form) players = [] @@ -279,15 +327,12 @@ class GameCreateView(LoginRequiredMixin, CreateView): pm_list = list(zip(players, musics)) random.shuffle(pm_list) for (player, music), order in zip(pm_list, range(1, len(pm_list) + 1)): - music.blacklisted = True - music.save() models.MusicGameOrder.objects.create( game=form.instance, player=player, music_video=music, order=order ) - if self.request.user.youtubecredentials: - form.instance.playlist_loading = True - form.instance.save() + form.instance.playlist_loading = True + form.instance.save() return res @@ -304,6 +349,12 @@ class GameDetailView(LoginRequiredMixin, DetailView): .distinct() ) + def get_context_data(self, **kwargs): + data = super().get_context_data(**kwargs) + data["is_leader"] = data["musikgame"].group.is_leader(self.request.user) + data["is_owner"] = data["musikgame"].group.is_owner(self.request.user) + return data + class YoutubeCallbackView(LoginRequiredMixin, View): def get(self, request): @@ -324,6 +375,13 @@ class YoutubeCallbackView(LoginRequiredMixin, View): flow.fetch_token(code=request.GET.get("code")) credentials = flow.credentials + + yt_api = googleapiclient.discovery.build( + "youtube", "v3", credentials=credentials + ) + channel_request = yt_api.channels().list(part="snippet", mine=True) + res = channel_request.execute() + models.YoutubeCredentials.objects.update_or_create( user=request.user, defaults={ @@ -334,10 +392,10 @@ class YoutubeCallbackView(LoginRequiredMixin, View): "client_id": credentials.client_id, "client_secret": credentials.client_secret, "granted_scopes": credentials.granted_scopes, - } + }, + "title": res["items"][0]["snippet"]["title"], }, ) - messages.add_message(request, messages.SUCCESS, "Connexion à Youtube réussie.") return redirect("/") @@ -358,11 +416,80 @@ class YoutubeLoginView(LoginRequiredMixin, View): return redirect(auth_url) -class GroupClearBlacklistView(OwnerFilterMixin, SingleObjectMixin, View): +class YoutubeLogoutView(LoginRequiredMixin, View): + def post(self, request): + request.user.youtubecredentials.delete() + return redirect("account_settings") + + +class GroupClearBlacklistView(MemberFilterMixin, SingleObjectMixin, View): model = models.Group def post(self, request, pk): group = self.get_object() + if not group.is_leader(request.user): + raise PermissionDenied() group.musicvideo_set.filter(blacklisted=True).update(blacklisted=False) messages.add_message(request, messages.SUCCESS, "La blacklist a été effacée.") return redirect(group) + + +class GameAnswerView(LoginRequiredMixin, DetailView): + model = models.MusikGame + template_name = "game/musikgame_answer.html" + + def get_queryset(self): + return super().get_queryset().filter(players=self.request.user) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "form": forms.AnswerForm(game=self.object, user=self.request.user) + } + + def post(self, request, pk): + game = self.get_object() + if game.over: + raise PermissionDenied() + for music in game.musicgameorder_set.all(): + answer = request.POST.get(f"answer-{music.order}") + if answer: + models.MusicGameAnswer.objects.update_or_create( + game=music, + player=request.user, + defaults={"answer": game.players.get(pk=answer)}, + ) + else: + models.MusicGameAnswer.objects.update_or_create( + game=music, + player=request.user, + defaults={"answer": None}, + ) + return redirect("game_answer", pk) + + +class GameEndView(LoginRequiredMixin, SingleObjectMixin, View): + model = models.MusikGame + + def get_queryset(self): + return super().get_queryset().filter(over=False) + + def post(self, request, pk): + game = self.get_object() + if not game.group.is_leader(request.user): + raise PermissionDenied() + game.over = True + models.MusicVideo.objects.filter(musicgameorder__game=game).update( + blacklisted=True + ) + + for go in game.musicgameorder_set.all(): + go.update_value() + + for player in game.players.all(): + score = player.musicgameanswer_set.score(game, player) + models.MusicGameResults.objects.create( + game=game, player=player, score=score + ) + + game.save() + return redirect("game_detail", pk) diff --git a/musik/context_processors.py b/musik/context_processors.py new file mode 100644 index 0000000..de709cd --- /dev/null +++ b/musik/context_processors.py @@ -0,0 +1,5 @@ +from django.conf import settings + + +def version(request): + return {"VERSION": settings.VERSION} diff --git a/musik/settings.py b/musik/settings.py index b12b987..0f097d7 100644 --- a/musik/settings.py +++ b/musik/settings.py @@ -13,6 +13,8 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ import os from pathlib import Path +VERSION = "0.4.4" + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -71,6 +73,7 @@ TEMPLATES = [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "musik.context_processors.version", ], }, }, @@ -141,3 +144,12 @@ YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY", "") YOUTUBE_OAUTH_SECRETS = os.getenv("YOUTUBE_OAUTH_SECRETS", "") CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", None) + +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = os.getenv("EMAIL_HOST") +EMAIL_PORT = os.getenv("EMAIL_PORT", 587) +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") +EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", False) +EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", not EMAIL_USE_SSL) +DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER) diff --git a/pyproject.toml b/pyproject.toml index 7656e66..0be7cd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "musik" -version = "0.1.0" +version = "0.4.4" description = "Le jeu de Musik." readme = "README.md" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index ce6f258..21e0387 100644 --- a/uv.lock +++ b/uv.lock @@ -66,11 +66,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.4.26" +version = "2025.6.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, + { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, ] [[package]] @@ -423,7 +423,7 @@ wheels = [ [[package]] name = "musik" -version = "0.1.0" +version = "0.4.4" source = { virtual = "." } dependencies = [ { name = "celery" },