Add the game suggestion system

This commit is contained in:
Guillaume Bertholon 2020-12-30 00:44:45 +01:00
parent 0ad6c7543e
commit 88a6f06f0d
17 changed files with 505 additions and 1 deletions

View file

@ -32,6 +32,7 @@ INSTALLED_APPS = [
"accounts", "accounts",
"comments", "comments",
"inventory", "inventory",
"suggestions",
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View file

@ -22,6 +22,7 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("markdownx/", include("markdownx.urls")), path("markdownx/", include("markdownx.urls")),
path("inventory/", include("inventory.urls")), path("inventory/", include("inventory.urls")),
path("suggestions/", include("suggestions.urls")),
path("account/", include("accounts.urls")), path("account/", include("accounts.urls")),
path("", include("website.urls")), path("", include("website.urls")),
] ]

1
suggestions/__init__.py Normal file
View file

@ -0,0 +1 @@
default_app_config = "suggestions.apps.SuggestionsConfig"

32
suggestions/admin.py Normal file
View file

@ -0,0 +1,32 @@
from django.contrib import admin
from .models import Suggestion, SuggestionComment
from comments.admin import CommentAdmin
class SuggestionAdmin(admin.ModelAdmin):
exclude = ("upvoting_users",)
list_display = ("title", "num_upvotes", "price")
actions = ["reset_upvotes"]
def num_upvotes(self, obj):
return obj.upvoting_users.all().count()
num_upvotes.short_description = "Nombre de votes"
def reset_upvotes(self, request, queryset):
SuggestionVote = Suggestion.upvoting_users.through
SuggestionVote.objects.filter(suggestion__in=queryset).delete()
self.message_user(
request,
"Les votes pour {} suggestions ont été réinitialisés".format(
queryset.count()
),
)
reset_upvotes.short_description = (
"Remettre à zero les votes pour les suggestions sélectionnées"
)
admin.site.register(Suggestion, SuggestionAdmin)
admin.site.register(SuggestionComment, CommentAdmin)

5
suggestions/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class SuggestionsConfig(AppConfig):
name = 'suggestions'

30
suggestions/forms.py Normal file
View file

@ -0,0 +1,30 @@
from django import forms
from .models import Suggestion
class SuggestionForm(forms.ModelForm):
error_css_class = "errorfield"
required_css_class = "requiredfield"
class Meta:
model = Suggestion
fields = [
"title",
"price",
"buy_link",
"nb_player_min",
"nb_player_max",
"player_range_precisions",
"duration",
"category",
"tags",
"game_designer",
"illustrator",
"editor",
"image",
"description",
]
class AddSuggestionForm(SuggestionForm):
comment = forms.CharField(widget=forms.Textarea, label="Commentaire personnel")

View file

@ -0,0 +1,64 @@
# Generated by Django 3.1.2 on 2020-12-29 23:43
import autoslug.fields
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import website.validators
class Migration(migrations.Migration):
initial = True
dependencies = [
('inventory', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Suggestion',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=256, unique=True, verbose_name='titre du jeu')),
('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='title', unique=True)),
('price', models.DecimalField(decimal_places=2, max_digits=6, validators=[django.core.validators.MinValueValidator(0)], verbose_name='prix en euros')),
('buy_link', models.URLField(verbose_name="lien vers un site d'achat")),
('nb_player_min', models.PositiveSmallIntegerField(verbose_name='nombre de joueur·se·s minimum')),
('nb_player_max', models.PositiveSmallIntegerField(verbose_name='nombre de joueur·se·s maximum')),
('player_range_precisions', models.CharField(blank=True, help_text='Pour indiquer une éventuelle contrainte (ex. parité) ou information sur le nombre de joueur·se·s', max_length=256, verbose_name='précisions sur le nombre de joueur·se·s')),
('duration', models.CharField(max_length=256, verbose_name='durée de partie')),
('game_designer', models.CharField(blank=True, max_length=256, verbose_name='game designer')),
('illustrator', models.CharField(blank=True, max_length=256, verbose_name='illustrateur·trice')),
('editor', models.CharField(blank=True, max_length=256, verbose_name='éditeur')),
('description', models.TextField(blank=True, help_text="Peut correspondre à celle de l'éditeur et ne doit pas contenir d'avis personnel", verbose_name='description')),
('image', models.ImageField(blank=True, help_text='Image du jeu de moins de 512 Kio à téléverser (par exemple une photo de sa boite)', upload_to='suggestion_img/', validators=[website.validators.MaxFileSizeValidator(512)], verbose_name='image')),
('category', models.ForeignKey(blank=True, help_text='Idée de catégorie dans laquelle ranger ce jeu', null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.category', verbose_name='catégorie')),
('tags', models.ManyToManyField(blank=True, help_text="Vous pouvez en sélectionner plusieurs ou aucun (sur ordinateur Ctrl+Clic change l'état de selection d'un tag)", to='inventory.Tag', verbose_name='tags qui correspondent à ce jeu')),
('upvoting_users', models.ManyToManyField(blank=True, related_name='upvoted_suggestions', to=settings.AUTH_USER_MODEL, verbose_name='personnes intéressées')),
],
options={
'verbose_name': 'suggestion de jeu',
'verbose_name_plural': 'suggestions de jeux',
'ordering': ['title'],
},
),
migrations.CreateModel(
name='SuggestionComment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField(verbose_name='texte')),
('created_on', models.DateTimeField(auto_now_add=True, verbose_name='date de publication')),
('modified_on', models.DateTimeField(auto_now=True, verbose_name='date de modification')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='auteur·ice')),
('commented_object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='suggestions.suggestion', verbose_name='suggestion')),
],
options={
'verbose_name': 'commentaire sur une suggestion',
'verbose_name_plural': 'commentaires sur des suggestions',
'ordering': ['created_on'],
},
),
]

View file

133
suggestions/models.py Normal file
View file

@ -0,0 +1,133 @@
from django.db import models
from django.urls import reverse
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from autoslug import AutoSlugField
from website.validators import MaxFileSizeValidator
from accounts.models import User
from inventory.models import Category, Tag
from comments.models import AbstractComment
class Suggestion(models.Model):
title = models.CharField(verbose_name="titre du jeu", max_length=256, unique=True)
slug = AutoSlugField(populate_from="title", unique=True)
price = models.DecimalField(
max_digits=6,
decimal_places=2,
verbose_name="prix en euros",
validators=[MinValueValidator(0)],
)
buy_link = models.URLField(verbose_name="lien vers un site d'achat")
nb_player_min = models.PositiveSmallIntegerField(
verbose_name="nombre de joueur·se·s minimum"
)
nb_player_max = models.PositiveSmallIntegerField(
verbose_name="nombre de joueur·se·s maximum"
)
player_range_precisions = models.CharField(
max_length=256,
blank=True,
verbose_name="précisions sur le nombre de joueur·se·s",
help_text="Pour indiquer une éventuelle contrainte (ex. parité) ou information sur le nombre de joueur·se·s",
)
duration = models.CharField(max_length=256, verbose_name="durée de partie")
game_designer = models.CharField(
max_length=256, blank=True, verbose_name="game designer"
)
illustrator = models.CharField(
max_length=256, blank=True, verbose_name="illustrateur·trice"
)
editor = models.CharField(max_length=256, blank=True, verbose_name="éditeur")
category = models.ForeignKey(
Category,
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name="catégorie",
help_text="Idée de catégorie dans laquelle ranger ce jeu",
)
tags = models.ManyToManyField(
Tag,
blank=True,
verbose_name="tags qui correspondent à ce jeu",
help_text="Vous pouvez en sélectionner plusieurs ou aucun (sur ordinateur Ctrl+Clic change l'état de selection d'un tag)",
)
description = models.TextField(
blank=True,
verbose_name="description",
help_text="Peut correspondre à celle de l'éditeur et ne doit pas contenir d'avis personnel",
)
image = models.ImageField(
upload_to="suggestion_img/",
blank=True,
verbose_name="image",
help_text="Image du jeu de moins de 512 Kio à téléverser (par exemple une photo de sa boite)",
validators=[MaxFileSizeValidator(512)],
)
upvoting_users = models.ManyToManyField(
User,
blank=True,
related_name="upvoted_suggestions",
verbose_name="personnes intéressées",
)
class Meta:
ordering = ["title"]
verbose_name = "suggestion de jeu"
verbose_name_plural = "suggestions de jeux"
def __str__(self):
return self.title
def clean(self):
if not self.nb_player_min or not self.nb_player_max:
return
if self.nb_player_min > self.nb_player_max:
raise ValidationError(
{
"nb_player_max": "Le nombre de joueur·se·s maximum doit être supérieur au nombre de joueur·se·s minimum"
}
)
def get_player_range(self):
precisions = ""
if self.player_range_precisions:
precisions = " ({})".format(self.player_range_precisions)
if self.nb_player_min != self.nb_player_max:
return "{} à {} joueur·se·s{}".format(
self.nb_player_min, self.nb_player_max, precisions
)
else:
return "{} joueur·se·s{}".format(self.nb_player_min, precisions)
def get_absolute_url(self):
return reverse("suggestions:suggestion", args=(self.slug,))
class SuggestionComment(AbstractComment):
commented_object = models.ForeignKey(
Suggestion,
on_delete=models.CASCADE,
related_name="comments",
verbose_name="suggestion",
)
class Meta:
ordering = ["created_on"]
verbose_name = "commentaire sur une suggestion"
verbose_name_plural = "commentaires sur des suggestions"
def get_modification_url(self):
return reverse(
"suggestions:modify_suggestion_comment",
args=(self.commented_object.slug, self.id),
)

View file

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block "content" %}
<h1>Ajout d'une suggestion</h1>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Envoyer la suggestion</button>
</form>
{% endblock %}

View file

@ -0,0 +1,26 @@
<a class="inventory_item suggestion" href="{{ suggestion.get_absolute_url }}">
<span class="title">{{ suggestion.title }}</span>
<span class="details">
<span><i class="fa fa-fw fa-thumbs-up" aria-hidden="true"></i> {{ suggestion.upvoting_users.count }}</span>
<span><i class="fa fa-fw fa-money" aria-hidden="true"></i> {{ suggestion.price }} €</span>
<span><i class="fa fa-fw fa-users" aria-hidden="true"></i> {{ suggestion.get_player_range }}</span>
{% if suggestion.duration %}
<span><i class="fa fa-fw fa-clock-o" aria-hidden="true"></i> {{ suggestion.duration }}</span>
{% endif %}
{% if suggestion.category %}
<span><i class="fa fa-fw fa-bookmark"></i> {{ suggestion.category }}</span>
{% endif %}
{% for tag in suggestion.tags.all %}
<span><i class="fa fa-fw fa-tag" aria-hidden="true"></i> {{ tag }}</span>
{% endfor %}
{% if suggestion.game_designer %}
<span><i class="fa fa-fw fa-wrench" aria-hidden="true"></i> {{ suggestion.game_designer }}</span>
{% endif %}
{% if suggestion.illustrator %}
<span><i class="fa fa-fw fa-paint-brush" aria-hidden="true"></i> {{ suggestion.illustrator }}</span>
{% endif %}
{% if suggestion.editor %}
<span><i class="fa fa-fw fa-cogs" aria-hidden="true"></i> {{ suggestion.editor }}</span>
{% endif %}
</span>
</a>

View file

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% load static %}
{% block "content" %}
<h1>{{ suggestion.title }}</h1>
<div id="game_infos">
{% if suggestion.image %}
<img src="{{ suggestion.image.url }}" alt="{{ suggestion.title }}"/>
{% endif %}
<div id="details">
<p><i class="fa fa-fw fa-money" aria-hidden="true"></i> <a href="{{ suggestion.buy_link }}" rel="nofollow">{{ suggestion.price }} €</a></p>
<hr/>
<p><i class="fa fa-fw fa-users" aria-hidden="true"></i> {{ suggestion.get_player_range }}</p>
<p><i class="fa fa-fw fa-clock-o" aria-hidden="true"></i> {{ suggestion.duration|default:"(Durée de jeu inconnue)" }}
<hr/>
<p><i class="fa fa-fw fa-bookmark" aria-hidden="true"></i> {% if suggestion.category %}<a href="{{ suggestion.category.get_absolute_url }}">{{ suggestion.category }}</a>{% else %}(Pas de catégorie){% endif %}</p>
<p><i class="fa fa-fw fa-tags" aria-hidden="true"></i>
{% for tag in suggestion.tags.all %}
<a href="{{ tag.get_absolute_url }}">{{ tag }}</a>{% if not forloop.last %},{% endif %}
{% empty %}
(Aucun tag)
{% endfor %}
</p>
<hr/>
<p><i class="fa fa-fw fa-wrench" aria-hidden="true"></i> {{ suggestion.game_designer|default:"(Game designer inconnu·e)" }}</li>
<p><i class="fa fa-fw fa-paint-brush" aria-hidden="true"></i> {{ suggestion.illustrator|default:"(Illustrateur·trice inconnu·e)" }}</li>
<p><i class="fa fa-fw fa-cogs" aria-hidden="true"></i> {{ suggestion.editor|default:"(Éditeur inconnu)" }}</p>
</div>
</div>
<h2>Votes pour cette suggestion</h2>
{% if suggestion.upvoting_users.count %}
<p>Actuellement, {{ suggestion.upvoting_users.count }} {{ suggestion.upvoting_users.count|pluralize:"personne aimerait,personnes aimeraient" }} avoir ce jeu dans la ludothèque.</p>
{% else %}
<p>Pour l'instant, personne n'est intéressé par ce jeu.</p>
{% endif %}
{% if request.user.is_authenticated %}
{% if request.user in suggestion.upvoting_users.all %}
<p>Vous faites partie des personnes intéressées.</p>
<a class="button" href="{% url "suggestions:downvote_suggestion" suggestion.slug %}"><i class="fa fa-thumbs-down" aria-hidden="true"></i> Annuler mon vote pour l'achat de ce jeu</a>
{% else %}
<a class="button" href="{% url "suggestions:upvote_suggestion" suggestion.slug %}"><i class="fa fa-thumbs-up" aria-hidden="true"></i> Voter pour acheter ce jeu</a>
{% endif %}
{% else %}
<p><a href="{% url "accounts:login" %}?next={{ request.get_full_path }}">Connectez-vous</a> pour voter pour une suggestion.</p>
{% endif %}
{% if suggestion.description %}
<h2 id="description">Description</h2>
{{ suggestion.description|linebreaks }}
{% endif %}
<h2>Avis et commentaires</h2>
{% url "suggestions:add_suggestion_comment" suggestion.slug as add_comment_url %}
{% include "comments.html" with comments=suggestion.comments.all %}
{% endblock %}

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block "content" %}
<h1>Liste des suggestions</h1>
<p>Ceci est la liste des achats suggérés par les membres du club jeux pour compléter la ludothèque.</p>
{% for suggestion in suggestion_list %}
{% include "./partials/suggestion_item.html" %}
{% empty %}
<p>(Aucune suggestion pour le moment)</p>
{% endfor %}
{% if request.user.is_authenticated %}
<a class="button" href="{% url "suggestions:add_suggestion" %}"><i class="fa fa-plus" aria-hidden="true"></i> Ajouter une suggestion</a>
{% else %}
<p><a href="{% url "accounts:login" %}?next={{ request.get_full_path }}">Connectez-vous</a> pour ajouter une suggestion.</p>
{% endif %}
{% endblock %}

3
suggestions/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

36
suggestions/urls.py Normal file
View file

@ -0,0 +1,36 @@
from django.urls import path
from .views import (
SuggestionListView,
AddSuggestionView,
SuggestionView,
UpvoteSuggestionView,
DownvoteSuggestionView,
AddSuggestionCommentView,
ModifySuggestionCommentView,
)
app_name = "suggestions"
urlpatterns = [
path("", SuggestionListView.as_view(), name="suggestions"),
path("add/", AddSuggestionView.as_view(), name="add_suggestion"),
path("item/<slug>/", SuggestionView.as_view(), name="suggestion"),
path(
"item/<slug>/upvote", UpvoteSuggestionView.as_view(), name="upvote_suggestion"
),
path(
"item/<slug>/downvote",
DownvoteSuggestionView.as_view(),
name="downvote_suggestion",
),
path(
"item/<slug>/add_comment",
AddSuggestionCommentView.as_view(),
name="add_suggestion_comment",
),
path(
"item/<slug>/modify_comment/<int:comment_id>",
ModifySuggestionCommentView.as_view(),
name="modify_suggestion_comment",
),
]

87
suggestions/views.py Normal file
View file

@ -0,0 +1,87 @@
from django.views.generic import ListView, DetailView, FormView, RedirectView
from django.views.generic.detail import SingleObjectMixin
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.shortcuts import redirect
from django.db.models import Count
from comments.views import AddCommentView, ModifyCommentView
from .models import Suggestion, SuggestionComment
from .forms import AddSuggestionForm
class SuggestionListView(ListView):
model = Suggestion
template_name = "suggestions/suggestion_list.html"
def get_queryset(self):
return Suggestion.objects.annotate(
num_upvotes=Count("upvoting_users")
).order_by("-num_upvotes")
class AddSuggestionView(LoginRequiredMixin, FormView):
template_name = "suggestions/add_suggestion.html"
form_class = AddSuggestionForm
def form_valid(self, form):
suggestion = form.save()
suggestion.upvoting_users.add(self.request.user)
suggestion.comments.create(
author=self.request.user, text=form.cleaned_data["comment"]
)
messages.success(self.request, "Votre suggestion est enregistrée")
return redirect("suggestions:suggestion", suggestion.slug)
class SuggestionView(DetailView):
model = Suggestion
template_name = "suggestions/suggestion.html"
class UpvoteSuggestionView(LoginRequiredMixin, SingleObjectMixin, RedirectView):
model = Suggestion
pattern_name = "suggestions:suggestion"
def get_redirect_url(self, *args, **kwargs):
suggestion = self.get_object()
if self.request.user not in suggestion.upvoting_users.all():
suggestion.upvoting_users.add(self.request.user)
messages.success(
self.request, "Votre vote pour cette suggestion est enregistré"
)
else:
messages.error(self.request, "Vous avez déjà voté pour cette suggestion")
return super().get_redirect_url(*args, **kwargs)
class DownvoteSuggestionView(LoginRequiredMixin, SingleObjectMixin, RedirectView):
model = Suggestion
pattern_name = "suggestions:suggestion"
def get_redirect_url(self, *args, **kwargs):
suggestion = self.get_object()
if self.request.user in suggestion.upvoting_users.all():
suggestion.upvoting_users.remove(self.request.user)
messages.success(
self.request, "Votre vote pour cette suggestion a été retiré"
)
else:
messages.error(
self.request, "Vous ne votiez déjà pas pour cette suggestion"
)
return super().get_redirect_url(*args, **kwargs)
class AddSuggestionCommentView(AddCommentView):
model = Suggestion
comment_model = SuggestionComment
pattern_name = "suggestions:suggestion"
class ModifySuggestionCommentView(ModifyCommentView):
model = Suggestion
comment_model = SuggestionComment
template_name = "suggestions/suggestion.html"
success_pattern_name = "suggestions:suggestion"

View file

@ -10,7 +10,7 @@
<div> <div>
<a {% if url_name == "home" %}class="current"{% endif %} href="{% url "website:home" %}"><i class="fa fa-home" aria-hidden="true"></i></a> <a {% if url_name == "home" %}class="current"{% endif %} href="{% url "website:home" %}"><i class="fa fa-home" aria-hidden="true"></i></a>
<a {% if url_name == "inventory" %}class="current"{% endif %} href="{% url "inventory:inventory" %}">Inventaire</a> <a {% if url_name == "inventory" %}class="current"{% endif %} href="{% url "inventory:inventory" %}">Inventaire</a>
<a {% if url_name == "suggestions" %}class="current"{% endif %} href="">Suggestions</a> <a {% if url_name == "suggestions" %}class="current"{% endif %} href="{% url "suggestions:suggestions" %}">Suggestions</a>
{% if request.user.is_staff %} {% if request.user.is_staff %}
<a href="{% url "admin:index" %}">Admin</a> <a href="{% url "admin:index" %}">Admin</a>
{% endif %} {% endif %}