Compare commits

...

41 commits
v0.1.5 ... main

Author SHA1 Message Date
8c2e62e8de
Fix formatting of favicon links in HTML template 2025-06-17 00:03:44 +02:00
e4168b474d
Bump version to 0.4.4 in settings, pyproject.toml, and uv.lock
All checks were successful
Build and push Docker image / build (push) Successful in 1m41s
2025-06-17 00:00:05 +02:00
b20eee8cfb
Add favicon assets and update HTML references for improved branding 2025-06-16 23:59:02 +02:00
a462fabde4
Bump version to 0.4.3 in settings, pyproject.toml, and uv.lock
All checks were successful
Build and push Docker image / build (push) Successful in 1m57s
2025-06-16 22:04:51 +02:00
b9711cbe9c
Refactor hero section layout and update footer privacy notice for clarity 2025-06-16 22:04:18 +02:00
bd8529cd01
Bump version to 0.4.2 in settings and pyproject.toml
All checks were successful
Build and push Docker image / build (push) Successful in 1m33s
2025-06-16 16:17:18 +02:00
84c432c325
Refactor value calculation in MusicGameOrder to improve scoring logic
Fix #13
Close #12
2025-06-16 16:16:11 +02:00
c639307cfb
Add restart policy for RabbitMQ and Postgres services in Docker Compose
Fix #11
2025-06-16 15:44:43 +02:00
951128147c
Fix playlist loading logic in MusikGame creation and update related template messages
Fix #10
2025-06-16 15:44:17 +02:00
0b8ce65a0a
Fix playlist display logic in musikgame_detail template
Fix #9
2025-06-16 15:33:23 +02:00
22bb6931e8
Remove individual blacklisting of music in GameCreateView and update all related music to blacklisted in GameEndView
Fix #8
2025-06-16 15:29:40 +02:00
cb3518a5e5
Change playlist privacy status from private to unlisted in generate_playlist task
Fix #7
2025-06-16 15:23:19 +02:00
e6d757c069
Update button text in musikgame_answer template for clarity
Fix #6
2025-06-16 15:19:06 +02:00
98183575af
Bump version to 0.4.1 in settings and pyproject files
All checks were successful
Build and push Docker image / build (push) Successful in 1m50s
2025-06-15 16:42:39 +02:00
c7b907f115
Refactor music management UI: separate group musics into its own template, enhance form structure, and improve responsiveness
Fix #5
2025-06-15 16:37:09 +02:00
b30ee77132
Enhance GroupAddMemberView to support adding multiple members and improve error handling for non-existent users 2025-06-15 16:24:51 +02:00
cc38d72df8
Add migration to alter MusicVideo model options and update group detail form to use textarea for YouTube IDs 2025-06-15 16:20:53 +02:00
486f650ea6
Add GameManager for filtering active games and update group_games template 2025-06-15 16:11:24 +02:00
302b884b23
Bump version to 0.4.0 in settings and pyproject files
All checks were successful
Build and push Docker image / build (push) Successful in 1m54s
2025-06-15 15:57:01 +02:00
993ed8963c
Highlight current user in game results and score display 2025-06-15 15:56:30 +02:00
2278345f32
Refactor game results display and scoring logic in templates and CSS 2025-06-15 15:46:35 +02:00
e039889488
Filter queryset in GameEndView to exclude completed games 2025-06-15 15:12:19 +02:00
92abcb584c
Add value field to MusicGameOrder and update score calculation logic 2025-06-15 15:09:48 +02:00
178a7cab03
Bump version to 0.3.0 in settings and pyproject files
All checks were successful
Build and push Docker image / build (push) Successful in 1m23s
2025-06-15 14:01:02 +02:00
3d585e1e14
Update game answer handling and UI for completed games 2025-06-15 13:59:22 +02:00
303538bf48
Add MusicGameResults model and score calculation logic in GameEndView 2025-06-15 13:34:37 +02:00
b1ec960dfa
Add podium styling and conditional rendering for players in musikgame detail template 2025-06-15 13:12:24 +02:00
33896916e6
Open results section by default in musikgame detail template 2025-06-15 13:04:05 +02:00
f9ed70d386
Add game end functionality with view and URL routing, update models and templates for game state management 2025-06-15 13:02:31 +02:00
d03d3b48d4
Add game answer functionality with form and view for user responses 2025-06-15 12:40:55 +02:00
6dbb1a54e0
Add MusicGameAnswer model with unique constraints and ordering 2025-06-15 12:04:28 +02:00
2478ec95e8
Update version to 0.2.0 and add version context processor for footer display
All checks were successful
Build and push Docker image / build (push) Successful in 2m21s
2025-06-15 11:38:10 +02:00
8faff47696
Add account deletion functionality with confirmation dialog and routing 2025-06-15 11:31:07 +02:00
cd0ca2f5ea
Add account settings page with YouTube connection management and user update functionality 2025-06-15 11:19:12 +02:00
da1c750771
Add password change and reset functionality with corresponding views and templates 2025-06-15 11:02:35 +02:00
a24fb897a3
Refactor user registration and login forms for improved structure and error handling 2025-06-15 10:42:16 +02:00
7259046916
Add email configuration settings to Django settings 2025-06-15 10:37:55 +02:00
83404e2ed5
Add title field to YoutubeCredentials model and update related templates and tasks 2025-06-15 10:25:40 +02:00
f36fe8ea84
Remove channel title from credentials in playlist generation and deletion tasks 2025-06-15 10:22:27 +02:00
28203bd630
Reorganize YouTube credentials display in home and form templates for consistency 2025-06-15 10:19:46 +02:00
da29634e57
Update YouTube credentials handling to include channel title in home template 2025-06-15 10:17:08 +02:00
51 changed files with 1139 additions and 248 deletions

View file

@ -17,7 +17,7 @@ form a[role="button"] {
width: 100%; width: 100%;
} }
i.owner { i.owner, .gold {
color: var(--pico-color-amber-200); color: var(--pico-color-amber-200);
} }
@ -25,7 +25,7 @@ i.owner {
display: none; display: none;
} }
a.group { a.group, a.running {
text-decoration: none; text-decoration: none;
} }
@ -77,11 +77,6 @@ article.message {
} }
#hero { #hero {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient( background: radial-gradient(
circle 50vh at calc(100vw - 4rem) 50%, circle 50vh at calc(100vw - 4rem) 50%,
var(--pico-primary-background), var(--pico-primary-background),
@ -90,19 +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) 30%, var(--pico-background-color)) 60%,
color-mix(in hsl, var(--pico-primary-background) 10%, var(--pico-background-color)) 80%, color-mix(in hsl, var(--pico-primary-background) 10%, var(--pico-background-color)) 80%,
var(--pico-background-color)); var(--pico-background-color));
display: grid; position: absolute;
grid-template-rows: 1fr min-content; top: 0;
align-items: center; bottom: 0;
left: 0;
right: 0;
height: 100%;
overflow-y: auto;
padding: 4rem; padding: 4rem;
.big-logo { main {
font-size: 8rem; display: contents;
} }
section {
h1 { max-width: 20rem;
font-size: 4rem;
} }
} }
.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, h1,
h2, h2,
@ -117,6 +134,9 @@ h6,
i.i { i.i {
margin-right: .5em; margin-right: .5em;
} }
i.hl, .me {
color: var(--pico-primary);
}
footer { footer {
text-align: center; text-align: center;
@ -130,3 +150,75 @@ td.c, th.c {
margin: 0; 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);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256" height="256" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="Gradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" style="stop-color:#AA40BF;stop-opacity:1" />
<stop offset="100%" style="stop-color:#AA40BF;stop-opacity:1" />
</linearGradient>
<filter id="alpha-to-white">
<feColorMatrix in="SourceGraphic" type="matrix"
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
</filter>
<g id="child-svg"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" height="16px" width="16px"><path d="M18.7134 8.12811L18.4668 8.69379C18.2864 9.10792 17.7136 9.10792 17.5331 8.69379L17.2866 8.12811C16.8471 7.11947 16.0555 6.31641 15.0677 5.87708L14.308 5.53922C13.8973 5.35653 13.8973 4.75881 14.308 4.57612L15.0252 4.25714C16.0384 3.80651 16.8442 2.97373 17.2761 1.93083L17.5293 1.31953C17.7058 0.893489 18.2942 0.893489 18.4706 1.31953L18.7238 1.93083C19.1558 2.97373 19.9616 3.80651 20.9748 4.25714L21.6919 4.57612C22.1027 4.75881 22.1027 5.35653 21.6919 5.53922L20.9323 5.87708C19.9445 6.31641 19.1529 7.11947 18.7134 8.12811ZM7 3H12V6H9V17C9 19.2091 7.20914 21 5 21C2.79086 21 1 19.2091 1 17C1 14.7909 2.79086 13 5 13C5.72857 13 6.41165 13.1948 7 13.5351V3ZM18 13.5351V11H20V17C20 19.2091 18.2091 21 16 21C13.7909 21 12 19.2091 12 17C12 14.7909 13.7909 13 16 13C16.7286 13 17.4117 13.1948 18 13.5351Z" /></svg></g>
</defs>
<rect
width="256"
height="256"
fill="url(#Gradient)"
ry="128"
x="0"
y="0" />
<use xlink:href="#child-svg" filter="url(#alpha-to-white)"
transform="matrix(8,0,0,8,64,64)" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View file

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<dialog open>
<article>
<header>
<p>
<i class="ri-user-fill"></i> {{ user.username }}
</p>
</header>
<p>
Confirmer la supression du compte <strong>{{ user.username }}</strong> ?
</p>
<p>
<small>Toute suppression est immédiate et définitive. La suppression du compte entraîne la suppression des groupes dont celui-ci est propriétaire.</small>
</p>
<form method="post">
{% csrf_token %}
<button type="submit">
<i class="ri-delete-bin-fill"></i> Confirmer
</button>
<a href="{% url "account_settings" %}" role="button" class="secondary"><i class="ri-close-line"></i> Annuler</a>
</form>
</article>
</dialog>
{% endblock content %}

View file

@ -1,6 +1,24 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load form %} {% load form %}
{% block content %} {% block content %}
<h1>Créer un compte</h1> <h1>Créer mon compte</h1>
{% form form %} {% for error in form.non_field_errors %}<article class="message error">{{ error }}</article>{% endfor %}
{% endblock content %} <form method="post" {% if action %}action="{% url action %}"{% endif %}>
{% csrf_token %}
<fieldset>
{% for field in form %}
<label for="{{ field.id_for_label }}">
{{ field.label }}
{{ field }}
{% if field.errors %}
<small id="{{ field.errors.field_id }}_error" class="form-error">{{ field.errors|join:", " }}</small>
{% endif %}
</label>
{% endfor %}
</fieldset>
<button type="submit">Créer mon compte</button>
<fieldset>
<a href="{% url "login" %}" role="button" class="secondary">J'ai déjà un compte</a>
</fieldset>
</form>
{% endblock content %}

View file

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% load form %}
{% block content %}
<h1>
<i class="ri-user-settings-fill"></i> Mon compte
</h1>
{% for error in form.non_field_errors %}<article class="message error">{{ error }}</article>{% endfor %}
<form method="post" {% if action %}action="{% url action %}"{% endif %}>
{% csrf_token %}
<fieldset>
<button type="submit" formaction="{% url "logout" %}" class="secondary">
<i class="ri-logout-box-r-fill"></i> Me déconnecter
</button>
</fieldset>
<fieldset>
{% if not user.youtubecredentials.credentials %}
<a href="{% url "youtube_login" %}" role="button"><i class="ri-youtube-fill"></i> Me connecter au compte Youtube</a>
{% else %}
<button type="submit"
formaction="{% url "youtube_logout" %}"
class="secondary">
<i class="ri-youtube-fill"></i> Déconnecter le compte Youtube <strong>{{ user.youtubecredentials.title }}</strong>
</button>
{% endif %}
</fieldset>
<fieldset>
{% for field in form %}
<label for="{{ field.id_for_label }}">
{{ field.label }}
{{ field }}
{% if field.errors %}
<small id="{{ field.errors.field_id }}_error" class="form-error">{{ field.errors|join:", " }}</small>
{% endif %}
</label>
{% endfor %}
<button type="submit">Mettre à jour mon compte</button>
</fieldset>
<fieldset>
<a href="{% url "password_change" %}" role="button" class="secondary">Changer mon mot de passe</a>
</fieldset>
<fieldset>
<a href="{% url "account_delete" %}" role="button" class="contrast">Supprimer mon compte</a>
</fieldset>
</form>
{% endblock content %}

View file

@ -11,7 +11,7 @@
Musik Musik
{% endblock title %} {% endblock title %}
</title> </title>
<link rel="icon" href="{% static "logo.svg" %}"> {% include "favicon.html" %}
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Exo:ital,wght@0,100..900;1,100..900&display=swap" <link href="https://fonts.googleapis.com/css2?family=Exo:ital,wght@0,100..900;1,100..900&display=swap"
@ -47,16 +47,8 @@
<nav> <nav>
<ul> <ul>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<li>{{ user.username }}</li>
<li> <li>
<form action="{% url 'logout' %}" method="post"> <a href="{% url "account_settings" %}"><i class="ri-user-fill" alt="Compte"></i> {{ user.username }}</a>
{% csrf_token %}
<input type="submit" value="Se déconnecter" class="logout">
</form>
</li>
{% else %}
<li>
<a href="{% url 'login' %}" role="button">Se connecter <i class="ri-login-box-fill"></i></a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View file

@ -0,0 +1,8 @@
{% load static %}
<meta name="theme-color" content="#aa40bf">
<link rel="icon" type="image/png" href="{% static "favicon/favicon-96x96.png" %}" sizes="96x96">
<link rel="icon" type="image/svg+xml" href="{% static "favicon/favicon.svg" %}">
<link rel="shortcut icon" href="{% static "favicon/favicon.ico" %}">
<link rel="apple-touch-icon" sizes="180x180" href="{% static "favicon/apple-touch-icon.png" %}">
<meta name="apple-mobile-web-app-title" content="Musik">
<link rel="manifest" href="{% static "favicon/site.webmanifest" %}">

View file

@ -1 +1 @@
© <a href="https://edgarpierre.fr">Edgar P. Burkhart</a> <a href="{% url "legal" %}">Mentions légales</a> Musik {{ VERSION }} © <a href="https://code.edgarpierre.fr/edpibu/musik">Edgar P. Burkhart</a> <a href="{% url "legal" %}">Mentions légales et confidentialité</a>

View file

@ -1,11 +1,25 @@
{% load static %} {% load static %}
<div id="hero"> <div id="hero">
<main> <main>
<i class="ri-music-ai-fill big-logo"></i> <div class="full-page">
<h1>Musik</h1> <div>
<p> <i class="ri-music-ai-fill big-logo"></i>
<a href="{% url "home" %}" role="button"><i class="ri-play-fill"></i> Jouer</a> <h1>Musik</h1>
</p> <p>
<a href="{% url "home" %}" role="button"><i class="ri-play-fill"></i> Jouer</a>
</p>
</div>
</div>
<div class="full-page r">
<section>
<h2>
<span class="brand-name"><i class="ri-music-ai-fill"></i> Musik</span> Le jeu où ta playlist devient ton arme secrète !
</h2>
<p>
Invite ta bande, ajoute tes sons fétiches, et cest 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 cest le jeu parfait pour tester vos oreilles… et vos amitiés. Prêt à jouer le DJ incognito ?
</p>
</section>
</div>
</main> </main>
<footer> <footer>
{% include "footer.html" %} {% include "footer.html" %}

View file

@ -16,6 +16,7 @@
Youtube est une marque de Google LLC. Youtube est une marque de Google LLC.
</p> </p>
<p> <p>
La suppression des données stockée par le service Musik pour son utilisation peut être demandée par email à <a href="mailto:contact@edgarpierre.fr">Edgar P. Burkhart</a>. 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.
</p> </p>
{% endblock content %} {% endblock content %}

View file

@ -1,9 +1,30 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load form %}
{% block content %} {% block content %}
<h1>Connexion</h1> <h1>Connexion</h1>
<p> {% for error in form.non_field_errors %}<article class="message error">{{ error }}</article>{% endfor %}
<a href="{% url "signup" %}">Créer un compte</a> <form method="post" {% if action %}action="{% url action %}"{% endif %}>
</p> {% csrf_token %}
{% form form submit="Se connecter" %} {% for field in form %}
{% endblock content %} {% if field.id_for_label %}
<label for="{{ field.id_for_label }}">
{{ field.label }}
{% else %}
<legend>{{ field.label }}</legend>
{% endif %}
{{ field }}
{% if field.errors %}
<small id="{{ field.errors.field_id }}_error" class="form-error">{{ field.errors|join:", " }}</small>
{% endif %}
</label>
{% endfor %}
<button type="submit">Me connecter</button>
<fieldset>
<a href="{% url "signup" %}" role="button" class="secondary">Créer mon compte</a>
</fieldset>
<fieldset>
<a href="{% url "password_reset" %}"
role="button"
class="outline secondary">J'ai oublié mon mot de passe</a>
</fieldset>
</form>
{% endblock content %}

View file

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% load form %}
{% block content %}
<h1>
<i class="ri-lock-password-line"></i> Changer mon mot de passe
</h1>
{% form form submit="Changer mon mot de passe" %}
{% endblock content %}

View file

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% load form %}
{% block content %}
<h1>
<i class="ri-lock-password-line"></i> Réinitialiser mon mot de passe
</h1>
{% form form submit="Réinitialiser mon mot de passe" %}
{% endblock content %}

View file

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% load form %}
{% block content %}
<h1>
<i class="ri-lock-password-line"></i> Réinitialiser mon mot de passe
</h1>
{% form form submit="Réinitialiser mon mot de passe" %}
{% endblock content %}

View file

@ -1,4 +1,5 @@
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 django.views.generic import TemplateView
from . import views from . import views
@ -6,6 +7,24 @@ from . import views
urlpatterns = [ urlpatterns = [
path("", views.HomePageView.as_view(), name="index"), path("", views.HomePageView.as_view(), name="index"),
path("accounts/signup/", views.SignupView.as_view(), name="signup"), 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/<uidb64>/<token>/",
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"), path("legal/", TemplateView.as_view(template_name="privacy.html"), name="legal"),
] ]

View file

@ -1,9 +1,10 @@
from django.contrib.auth import views as auth_views
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views.generic.base import TemplateView 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 from . import forms
@ -22,3 +23,42 @@ class SignupView(SuccessMessageMixin, CreateView):
form_class = forms.UserSignupForm form_class = forms.UserSignupForm
success_url = reverse_lazy("login") success_url = reverse_lazy("login")
success_message = "Le compte %(username)s a été créé avec succès." 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)

View file

@ -32,10 +32,12 @@ services:
rabbitmq: rabbitmq:
image: rabbitmq image: rabbitmq
container_name: musik_rabbitmq container_name: musik_rabbitmq
restart: unless-stopped
postgres: postgres:
image: postgres:17 image: postgres:17
container_name: musik_postgres container_name: musik_postgres
restart: unless-stopped
env_file: stack.env env_file: stack.env
volumes: volumes:
- /docker/musik/postgres:/var/lib/postgresql/data - /docker/musik/postgres:/var/lib/postgresql/data

View file

@ -39,3 +39,23 @@ class MusikGameForm(forms.ModelForm):
kwargs["initial"].setdefault("players", players) kwargs["initial"].setdefault("players", players)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["players"].queryset = players 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,
)

View file

@ -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),
),
]

View file

@ -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"
)
],
},
),
]

View file

@ -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),
),
]

View file

@ -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"
)
],
},
),
]

View file

@ -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),
),
]

View file

@ -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),
),
]

View file

@ -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"]},
),
]

View file

@ -1,5 +1,6 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.db.models import F
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
@ -11,6 +12,7 @@ from . import tasks
class YoutubeCredentials(models.Model): class YoutubeCredentials(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE) user = models.OneToOneField(User, on_delete=models.CASCADE)
credentials = models.JSONField() credentials = models.JSONField()
title = models.CharField(blank=True)
class Group(models.Model): class Group(models.Model):
@ -64,6 +66,12 @@ class MusicVideo(models.Model):
fields=("yt_id", "owner", "group"), name="unique_music_in_group" 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): class MusikGame(models.Model):
@ -73,10 +81,16 @@ class MusikGame(models.Model):
players = models.ManyToManyField(User, verbose_name="Joueurs") players = models.ManyToManyField(User, verbose_name="Joueurs")
playlist = models.CharField(blank=True, verbose_name="Playlist YouTube") playlist = models.CharField(blank=True, verbose_name="Playlist YouTube")
playlist_loading = models.BooleanField(default=False) playlist_loading = models.BooleanField(default=False)
over = models.BooleanField(default=False)
objects = GameManager()
def get_absolute_url(self): def get_absolute_url(self):
return reverse("game_detail", kwargs={"pk": self.pk}) return reverse("game_detail", kwargs={"pk": self.pk})
class Meta:
ordering = ["over", "-date"]
@receiver(post_save, sender=MusikGame) @receiver(post_save, sender=MusikGame)
def generateYoutubePlaylist(sender, instance, created, **kwargs): def generateYoutubePlaylist(sender, instance, created, **kwargs):
@ -85,6 +99,9 @@ def generateYoutubePlaylist(sender, instance, created, **kwargs):
if creds := instance.group.owner.youtubecredentials: if creds := instance.group.owner.youtubecredentials:
tasks.generate_playlist.delay_on_commit(creds.credentials, instance.pk) tasks.generate_playlist.delay_on_commit(creds.credentials, instance.pk)
else:
instance.playlist_loading = False
instance.save()
@receiver(post_delete, sender=MusikGame) @receiver(post_delete, sender=MusikGame)
@ -101,6 +118,14 @@ class MusicGameOrder(models.Model):
player = models.ForeignKey(User, on_delete=models.CASCADE) player = models.ForeignKey(User, on_delete=models.CASCADE)
music_video = models.ForeignKey(MusicVideo, on_delete=models.CASCADE) music_video = models.ForeignKey(MusicVideo, on_delete=models.CASCADE)
order = models.PositiveIntegerField() 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: class Meta:
constraints = [ constraints = [
@ -110,3 +135,44 @@ class MusicGameOrder(models.Model):
models.UniqueConstraint(fields=("game", "order"), name="unique_order"), models.UniqueConstraint(fields=("game", "order"), name="unique_order"),
] ]
ordering = ["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"]

View file

@ -18,7 +18,7 @@ def generate_playlist(creds, game_pk):
"description": "Playlist générée par Musik", "description": "Playlist générée par Musik",
}, },
"status": { "status": {
"privacyStatus": "private", "privacyStatus": "unlisted",
}, },
}, },
) )

View file

@ -1,5 +1,4 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load form %}
{% block content %} {% block content %}
<dialog open> <dialog open>
<article> <article>

View file

@ -10,115 +10,7 @@
{{ group.name }} {{ group.name }}
</h1> </h1>
{% include "game/include/group_buttons.html" %} {% include "game/include/group_buttons.html" %}
{% if group.musikgame_set.exists %} {% include "game/include/group_games.html" %}
<h2>
<i class="ri-play-circle-fill"></i> Parties
</h2>
<form method="post">
{% csrf_token %}
<table>
<thead>
{% if group.owner == user %}<th></th>{% endif %}
<th>Date</th>
<th>
<i class="ri-youtube-fill"></i> Playlists
</th>
<th>Joueurs</th>
</thead>
<tbody>
{% for game in group.musikgame_set.all %}
<tr>
{% if group.owner == user %}
<td>
<input type="checkbox" name="game" value="{{ game.pk }}">
</td>
{% endif %}
<td>
<a href="{% url "game_detail" pk=game.pk %}">{{ game.date }}</a>
</td>
<td>
{% if game.playlist %}
<a href="{% yt_playlist game %}"
{% if game.playlist_loading %}aria-busy="true"{% endif %}><i class="ri-youtube-fill"></i> Playlist</a>
{% endif %}
</td>
<td>{{ game.players.all|join:", " }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if group.owner == user %}
<button type="submit"
class="secondary"
formaction="{% url "group_remove_game" pk=group.pk %}">
<i class="ri-delete-bin-fill"></i> Supprimer les parties sélectionnées
</button>
{% endif %}
</form>
{% endif %}
{% include "game/include/group_members.html" %} {% include "game/include/group_members.html" %}
<h2> {% include "game/include/group_musics.html" %}
<i class="ri-music-2-fill"></i> Mes musiques <span class="music-count">{{ musics.count }}</span>
</h2>
<details>
<summary role="button">
<i class="ri-music-2-fill"></i> Liste des musiques
</summary>
<form method="post">
{% csrf_token %}
<table>
<thead>
<tr>
<th></th>
<th>Musique</th>
<th>ID</th>
<th>
<i class="ri-history-fill"></i>
</th>
</tr>
</thead>
<tbody>
{% for music in musics %}
<tr>
<td>
<input type="checkbox" name="musics" value="{{ music.pk }}">
</td>
<th>{{ music.title }}</th>
<td>
<a href="https://youtu.be/{{ music.yt_id }}">{{ music.yt_id }}</a>
</td>
<td>
<input type="checkbox" disabled {% if music.blacklisted %}checked{% endif %}>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4">Aucune musique.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if musics %}
<fieldset role="group">
<button type="submit"
formaction="{% url "group_remove_music" pk=group.pk %}"
class="secondary">
<i class="ri-delete-bin-fill"></i> Supprimer
</button>
<button type="submit"
class="secondary"
formaction="{% url "group_unblacklist_music" pk=group.pk %}">
<i class="ri-history-fill"></i> Retirer de la blacklist
</button>
</fieldset>
{% endif %}
</form>
</details>
<form method="post" action="{% url "group_add_music" pk=group.pk %}">
{% csrf_token %}
<fieldset role="group">
<input type="string" name="yt_id" id="yt_id" placeholder="Musique" required>
<button type="submit">Ajouter</button>
</fieldset>
</form>
{% endblock content %} {% endblock content %}

View file

@ -4,15 +4,12 @@
<h1> <h1>
<i class="ri-music-ai-fill"></i> Musik <i class="ri-music-ai-fill"></i> Musik
</h1> </h1>
<p>Bienvenue {{ user.username }} !</p> <p>
Bienvenue <strong>{{ user.username }}</strong> !
</p>
<h2> <h2>
<i class="ri-group-2-fill"></i> Mes groupes <i class="ri-group-2-fill"></i> Mes groupes
</h2> </h2>
<p>
{% if not user.youtubecredentials.credentials %}
<a href="{% url "youtube_login" %}" role="button"><i class="ri-youtube-fill"></i> Me connecter au compte Youtube</a>
{% endif %}
</p>
{% if user.owned_group_set.exists or user.group_set.exists %} {% if user.owned_group_set.exists or user.group_set.exists %}
{% for group in user.owned_group_set.all %} {% for group in user.owned_group_set.all %}
<a class="group" href="{{ group.get_absolute_url }}"> <a class="group" href="{{ group.get_absolute_url }}">

View file

@ -0,0 +1,49 @@
{% load game %}
{% if musikgame.over %}
<h2>
<i class="ri-list-ordered"></i> Résultats
</h2>
<details open>
<summary role="button">
<i class="ri-list-ordered-2"></i> Résultats
</summary>
<div class="overflow-auto">
<table class="striped results">
<thead>
<tr>
<th>
<i class="ri-list-ordered-2"></i>
</th>
<th>
<i class="ri-music-2-fill"></i> Musique
</th>
<th>
<i class="ri-user-line"></i> Joueur
</th>
{% for player in musikgame.musicgameresults_set.all %}
<th {% if user == player.player %}class="me"{% endif %}>{{ player.player.username }}</th>
<th class="sc">
{% if forloop.first %}<i class="ri-medal-fill gold"></i>{% endif %}
<span class="score">{{ player.score }}</span>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for music in musikgame.musicgameorder_set.all %}
<tr>
<td>{{ music.order }}</td>
<td>
<a href="https://youtu.be/{{ music.music_video.yt_id }}">{{ music.music_video.title }}</a>
</td>
<td>{{ music.player }}</td>
{% for player in musikgame.musicgameresults_set.all %}
{% answer player music %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endif %}

View file

@ -0,0 +1,62 @@
{% load form youtube %}
{% if group.musikgame_set.exists %}
{% for game in group.musikgame_set.playing %}
<a class="running" href="{{ game.get_absolute_url }}">
<article><i class="ri-play-circle-fill i"></i>{{ game.date }}</article>
</a>
{% endfor %}
<h2>
<i class="ri-play-circle-fill"></i> Parties
</h2>
<form method="post">
{% csrf_token %}
<table>
<thead>
{% if group.owner == user %}<th></th>{% endif %}
<th>
<i class="ri-play-circle-fill"></i>
</th>
<th>Date</th>
<th>
<i class="ri-youtube-fill"></i> Playlists
</th>
<th>Joueurs</th>
</thead>
<tbody>
{% for game in group.musikgame_set.all %}
<tr>
{% if group.owner == user %}
<td>
<input type="checkbox" name="game" value="{{ game.pk }}">
</td>
{% endif %}
<td>
{% if game.over %}
<i class="ri-stop-circle-fill"></i>
{% else %}
<i class="ri-play-circle-fill hl"></i>
{% endif %}
</td>
<td>
<a href="{% url "game_detail" pk=game.pk %}">{{ game.date }}</a>
</td>
<td>
{% if game.playlist %}
<a href="{% yt_playlist game %}"
{% if game.playlist_loading %}aria-busy="true"{% endif %}><i class="ri-youtube-fill"></i> Playlist</a>
{% endif %}
</td>
<td>{{ game.players.all|join:", " }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if group.owner == user %}
<button type="submit"
class="secondary"
formaction="{% url "group_remove_game" pk=group.pk %}">
<i class="ri-delete-bin-fill"></i> Supprimer les parties sélectionnées
</button>
{% endif %}
</form>
{% endif %}

View file

@ -12,7 +12,7 @@
<i class="ri-vip-crown-fill"></i> <i class="ri-vip-crown-fill"></i>
</th> </th>
<th> <th>
<i class="ri-mv-line"></i> <i class="ri-mv-fill"></i>
</th> </th>
</tr> </tr>
</thead> </thead>

View file

@ -0,0 +1,66 @@
<h2>
<i class="ri-music-2-fill"></i> Mes musiques <span class="music-count">{{ musics.count }}</span>
</h2>
<details>
<summary role="button">
<i class="ri-music-2-fill"></i> Liste des musiques
</summary>
<form method="post">
{% csrf_token %}
<div class="overflow-auto">
<table class="musics">
<thead>
<tr>
<th></th>
<th>Musique</th>
<th>ID</th>
<th>
<i class="ri-history-fill"></i>
</th>
</tr>
</thead>
<tbody>
{% for music in musics %}
<tr>
<td>
<input type="checkbox" name="musics" value="{{ music.pk }}">
</td>
<th>{{ music.title }}</th>
<td>
<a href="https://youtu.be/{{ music.yt_id }}">{{ music.yt_id }}</a>
</td>
<td>
<input type="checkbox" disabled {% if music.blacklisted %}checked{% endif %}>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4">Aucune musique.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if musics %}
<fieldset role="group">
<button type="submit"
formaction="{% url "group_remove_music" pk=group.pk %}"
class="secondary">
<i class="ri-delete-bin-fill"></i> Supprimer
</button>
<button type="submit"
class="secondary"
formaction="{% url "group_unblacklist_music" pk=group.pk %}">
<i class="ri-history-fill"></i> Retirer de la blacklist
</button>
</fieldset>
{% endif %}
</form>
</details>
<form method="post" action="{% url "group_add_music" pk=group.pk %}">
{% csrf_token %}
<fieldset>
<textarea name="yt_id" id="yt_id" placeholder="Musiques" required></textarea>
<button type="submit">Ajouter</button>
</fieldset>
</form>

View file

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block content %}
<h2>Mes réponses</h2>
<form method="post">
{% csrf_token %}
<table>
<thead>
<tr>
<th>
<i class="ri-mv-fill" alt="Musique"></i>
</th>
<th>Réponse</th>
</tr>
</thead>
<tbody>
{% for field in form %}
<tr>
<td>{{ field.label }}</td>
<td>{{ field }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not musikgame.over %}<button type="submit">Sauvegarder mes réponses</button>{% endif %}
</form>
{% endblock content %}

View file

@ -2,20 +2,52 @@
{% load youtube %} {% load youtube %}
{% block content %} {% block content %}
<h1> <h1>
<i class="ri-play-circle-fill"></i> {{ musikgame.date }} {% if musikgame.over %}
<i class="ri-stop-circle-fill"></i>
{% else %}
<i class="ri-play-circle-fill hl"></i>
{% endif %}
{{ musikgame.date }}
</h1> </h1>
{% if musikgame.playlist or musikgame.playlist_loading %} <form method="post">
<p> {% csrf_token %}
<a target="_blank" <fieldset role="group">
href="{% yt_playlist musikgame %}" {% if musikgame.playlist or musikgame.playlist_loading %}
role="button" <a target="_blank"
{% if musikgame.playlist_loading %}aria-busy="true"{% endif %}><i class="ri-youtube-fill"></i> Playlist</a> href="{% yt_playlist musikgame %}"
</p> role="button"
{% endif %} {% if musikgame.playlist_loading %}aria-busy="true"{% endif %}><i class="ri-youtube-fill"></i> Playlist</a>
{% endif %}
{% if musikgame.over %}
<a href="{% url "game_answer" musikgame.pk %}"
role="button"
class="secondary"><i class="ri-play-list-2-fill"></i> Mes réponses</a>
{% else %}
<a href="{% url "game_answer" musikgame.pk %}" role="button"><i class="ri-play-list-2-fill"></i> Répondre</a>
{% endif %}
</fieldset>
{% if is_leader and not musikgame.over %}
<fieldset>
<button type="submit" formaction="{% url "game_end" musikgame.pk %}">
<i class="ri-stop-circle-fill"></i> Finir la partie
</button>
</fieldset>
{% endif %}
</form>
<h2> <h2>
<i class="ri-group-2-fill"></i> Joueurs <i class="ri-group-2-fill"></i> Joueurs
</h2> </h2>
<p>{{ musikgame.players.all|join:", " }}</p> {% if musikgame.over %}
<ol class="podium">
{% for player in musikgame.musicgameresults_set.all %}
<li {% if user == player.player %}class="me"{% endif %}>
{{ player.player.username }} <span class="score">{{ player.score }}</span>
</li>
{% endfor %}
</ol>
{% else %}
<p>{{ musikgame.players.all|join:", " }}</p>
{% endif %}
<h2> <h2>
<i class="ri-music-2-fill"></i> Musiques <i class="ri-music-2-fill"></i> Musiques
</h2> </h2>
@ -31,38 +63,5 @@
{% endfor %} {% endfor %}
</ol> </ol>
</details> </details>
<h2> {% include "game/include/game_results.html" %}
<i class="ri-list-ordered"></i> Résultats
</h2>
<details>
<summary role="button">
<i class="ri-list-ordered-2"></i> Résultats
</summary>
<table class="striped">
<thead>
<tr>
<th>
<i class="ri-list-ordered-2"></i>
</th>
<th>
<i class="ri-music-2-fill"></i> Musique
</th>
<th>
<i class="ri-user-line"></i> Joueur
</th>
</tr>
</thead>
<tbody>
{% for music in musikgame.musicgameorder_set.all %}
<tr>
<td>{{ music.order }}</td>
<td>
<a href="https://youtu.be/{{ music.music_video.yt_id }}">{{ music.music_video.title }}</a>
</td>
<td>{{ music.player }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</details>
{% endblock content %} {% endblock content %}

View file

@ -4,5 +4,14 @@
<h1> <h1>
<i class="ri-group-2-fill"></i> {{ group.name }} <i class="ri-group-2-fill"></i> {{ group.name }}
</h1> </h1>
<p>
{% if group.owner.youtubecredentials.credentials %}
<i class="ri-youtube-fill"></i> Une playlist sera générée automatiquement sur le compte Youtube de <strong>{{ group.owner }}</strong> (<strong>{{ group.owner.youtubecredentials.title }}</strong>).
{% elif user == group.owner %}
<a href="{% url "youtube_login" %}" role="button"><i class="ri-youtube-fill"></i> Connecter mon compte Youtube</a>
{% else %}
<small>Aucune playlist Youtube ne sera générée car <strong>{{ group.owner }}</strong> n'a pas lié son compte Youtube.</small>
{% endif %}
</p>
{% form form %} {% form form %}
{% endblock content %} {% endblock content %}

View file

@ -0,0 +1,16 @@
{% if empty %}
<td class="empty"></td>
<td class="empty sc">
<i class="ri-checkbox-blank-circle-fill"></i><span class="score">{{ score }}</span>
</td>
{% elif correct %}
<td class="correct">{{ answer }}</td>
<td class="correct sc">
<i class="ri-checkbox-circle-fill"></i><span class="score">{{ score }}</span>
</td>
{% else %}
<td class="wrong">{{ answer }}</td>
<td class="wrong sc">
<i class="ri-close-circle-fill"></i><span class="score">{{ score }}</span>
</td>
{% endif %}

30
game/templatetags/game.py Normal file
View file

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

View file

@ -52,6 +52,7 @@ urlpatterns = [
), ),
path("group/game/<int:pk>/", views.GameDetailView.as_view(), name="game_detail"), path("group/game/<int:pk>/", views.GameDetailView.as_view(), name="game_detail"),
path("youtube_login/", views.YoutubeLoginView.as_view(), name="youtube_login"), path("youtube_login/", views.YoutubeLoginView.as_view(), name="youtube_login"),
path("youtube_logout/", views.YoutubeLogoutView.as_view(), name="youtube_logout"),
path( path(
"youtube_callback/", "youtube_callback/",
views.YoutubeCallbackView.as_view(), views.YoutubeCallbackView.as_view(),
@ -62,4 +63,10 @@ urlpatterns = [
views.GroupClearBlacklistView.as_view(), views.GroupClearBlacklistView.as_view(),
name="group_clear_blacklist", name="group_clear_blacklist",
), ),
path(
"group/game/<int:pk>/answer/",
views.GameAnswerView.as_view(),
name="game_answer",
),
path("group/game/<int:pk>/end/", views.GameEndView.as_view(), name="game_end"),
] ]

View file

@ -1,6 +1,7 @@
import random import random
import google_auth_oauthlib import google_auth_oauthlib
import googleapiclient
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -103,28 +104,31 @@ class GroupAddMusicView(MemberFilterMixin, SingleObjectMixin, View):
def post(self, request, pk): def post(self, request, pk):
group = self.get_object() group = self.get_object()
yt_id = request.POST.get("yt_id") ids = request.POST.get("yt_id")
if not yt_id: for yt_id in ids.split():
messages.add_message(request, messages.ERROR, "Aucun identifiant donné") if not yt_id:
return redirect(group) messages.add_message(request, messages.ERROR, "Aucun identifiant donné")
yt_id = utils.parse_musik(yt_id) return redirect(group)
yt_id = utils.parse_musik(yt_id)
title = utils.get_yt_title(yt_id) title = utils.get_yt_title(yt_id)
if not title: if not title:
messages.add_message( messages.add_message(
request, messages.ERROR, f"Vidéo Youtube invalide : {yt_id}" request, messages.ERROR, f"Vidéo Youtube invalide : {yt_id}"
) )
return redirect(group) else:
try: try:
group.musicvideo_set.create(yt_id=yt_id, title=title, owner=request.user) group.musicvideo_set.create(
except IntegrityError: yt_id=yt_id, title=title, owner=request.user
messages.add_message( )
request, messages.ERROR, f"Vidéo Youtube déjà ajoutée : {yt_id}" except IntegrityError:
) messages.add_message(
request, messages.ERROR, f"Vidéo Youtube déjà ajoutée : {yt_id}"
)
messages.add_message( messages.add_message(
request, messages.SUCCESS, f"Vidéo Youtube ajoutée : {yt_id}" request, messages.SUCCESS, f"Vidéo Youtube ajoutée : {yt_id}"
) )
return redirect(group) return redirect(group)
@ -135,19 +139,24 @@ class GroupAddMemberView(MemberFilterMixin, SingleObjectMixin, View):
group = self.get_object() group = self.get_object()
if not group.is_leader(request.user): if not group.is_leader(request.user):
raise PermissionDenied() raise PermissionDenied()
username = request.POST.get("username") usernames = request.POST.get("username")
user = User.objects.get(username=username) for username in usernames.split():
if user == group.owner: user = User.objects.filter(username=username).first()
messages.add_message( if not user:
request, messages.WARNING, f"{user} est le propriétaire du groupe." messages.add_message(
) request, messages.ERROR, f"{username} n'existe pas."
return redirect(group) )
if user in group.members.all(): elif user == group.owner:
messages.add_message( messages.add_message(
request, messages.WARNING, f"{user} est déjà membre du groupe." 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) return redirect(group)
@ -318,15 +327,12 @@ class GameCreateView(LoginRequiredMixin, CreateView):
pm_list = list(zip(players, musics)) pm_list = list(zip(players, musics))
random.shuffle(pm_list) random.shuffle(pm_list)
for (player, music), order in zip(pm_list, range(1, len(pm_list) + 1)): for (player, music), order in zip(pm_list, range(1, len(pm_list) + 1)):
music.blacklisted = True
music.save()
models.MusicGameOrder.objects.create( models.MusicGameOrder.objects.create(
game=form.instance, player=player, music_video=music, order=order game=form.instance, player=player, music_video=music, order=order
) )
if models.YoutubeCredentials.objects.filter(user=self.request.user).exists(): form.instance.playlist_loading = True
form.instance.playlist_loading = True form.instance.save()
form.instance.save()
return res return res
@ -343,6 +349,12 @@ class GameDetailView(LoginRequiredMixin, DetailView):
.distinct() .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): class YoutubeCallbackView(LoginRequiredMixin, View):
def get(self, request): def get(self, request):
@ -363,6 +375,13 @@ class YoutubeCallbackView(LoginRequiredMixin, View):
flow.fetch_token(code=request.GET.get("code")) flow.fetch_token(code=request.GET.get("code"))
credentials = flow.credentials 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( models.YoutubeCredentials.objects.update_or_create(
user=request.user, user=request.user,
defaults={ defaults={
@ -373,10 +392,10 @@ class YoutubeCallbackView(LoginRequiredMixin, View):
"client_id": credentials.client_id, "client_id": credentials.client_id,
"client_secret": credentials.client_secret, "client_secret": credentials.client_secret,
"granted_scopes": credentials.granted_scopes, "granted_scopes": credentials.granted_scopes,
} },
"title": res["items"][0]["snippet"]["title"],
}, },
) )
messages.add_message(request, messages.SUCCESS, "Connexion à Youtube réussie.") messages.add_message(request, messages.SUCCESS, "Connexion à Youtube réussie.")
return redirect("/") return redirect("/")
@ -397,6 +416,12 @@ class YoutubeLoginView(LoginRequiredMixin, View):
return redirect(auth_url) return redirect(auth_url)
class YoutubeLogoutView(LoginRequiredMixin, View):
def post(self, request):
request.user.youtubecredentials.delete()
return redirect("account_settings")
class GroupClearBlacklistView(MemberFilterMixin, SingleObjectMixin, View): class GroupClearBlacklistView(MemberFilterMixin, SingleObjectMixin, View):
model = models.Group model = models.Group
@ -407,3 +432,64 @@ class GroupClearBlacklistView(MemberFilterMixin, SingleObjectMixin, View):
group.musicvideo_set.filter(blacklisted=True).update(blacklisted=False) group.musicvideo_set.filter(blacklisted=True).update(blacklisted=False)
messages.add_message(request, messages.SUCCESS, "La blacklist a été effacée.") messages.add_message(request, messages.SUCCESS, "La blacklist a été effacée.")
return redirect(group) 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)

View file

@ -0,0 +1,5 @@
from django.conf import settings
def version(request):
return {"VERSION": settings.VERSION}

View file

@ -13,6 +13,8 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
import os import os
from pathlib import Path from pathlib import Path
VERSION = "0.4.4"
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@ -71,6 +73,7 @@ TEMPLATES = [
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "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", "") YOUTUBE_OAUTH_SECRETS = os.getenv("YOUTUBE_OAUTH_SECRETS", "")
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", None) 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)

View file

@ -1,6 +1,6 @@
[project] [project]
name = "musik" name = "musik"
version = "0.1.5" version = "0.4.4"
description = "Le jeu de Musik." description = "Le jeu de Musik."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

2
uv.lock generated
View file

@ -423,7 +423,7 @@ wheels = [
[[package]] [[package]]
name = "musik" name = "musik"
version = "0.1.5" version = "0.4.4"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "celery" }, { name = "celery" },