diff --git a/.gitignore b/.gitignore index b7d5fc6..a5d453a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ app/static/avatars/ app/static/images/trophies/ -# Development files +## Development files # Flask env .env @@ -17,6 +17,8 @@ venv/ # pipenv Pipfile Pipfile.lock +# Tests files +test.* ## Deployment files diff --git a/app/forms/poll.py b/app/forms/poll.py new file mode 100644 index 0000000..4a003ff --- /dev/null +++ b/app/forms/poll.py @@ -0,0 +1,38 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, TextAreaField +from wtforms.fields.html5 import DateTimeField +from wtforms.validators import InputRequired, Optional + +from datetime import datetime, timedelta + +class PollForm(FlaskForm): + title = StringField( + 'Question', + validators=[ + InputRequired(), + ] + ) + choices = TextAreaField( + 'Choix (un par ligne)', + validators=[ + InputRequired(), + # TODO: add a validator to check if there is at least one choice + ] + ) + start = DateTimeField( + 'Début', + default=datetime.now(), + validators=[ + Optional() + ] + ) + end = DateTimeField( + 'Fin', + default=datetime.now() + timedelta(days=1), + validators=[ + Optional() + ] + ) + submit = SubmitField( + 'Créer le sondage' + ) diff --git a/app/models/comment.py b/app/models/comment.py index 8343e79..e9834d1 100644 --- a/app/models/comment.py +++ b/app/models/comment.py @@ -17,8 +17,9 @@ class Comment(Post): thread_id = db.Column(db.Integer, db.ForeignKey('thread.id'), nullable=False) thread = db.relationship('Thread', - backref=backref('comments', lazy='dynamic'), - foreign_keys=thread_id) + backref=backref('comments', lazy='dynamic'), + foreign_keys=thread_id) + def __init__(self, author, text, thread): """ diff --git a/app/models/poll.py b/app/models/poll.py new file mode 100644 index 0000000..9a6d0b2 --- /dev/null +++ b/app/models/poll.py @@ -0,0 +1,123 @@ +from app import db +from enum import Enum +from sqlalchemy.orm import backref +from datetime import datetime, timedelta +from collections import Counter + + +class Poll(db.Model): + """Default class for polls""" + + __tablename__ = 'poll' + + # Names of templates + template = 'defaultpoll.html' + + # Unique ID + id = db.Column(db.Integer, primary_key=True) + + # Type + type = db.Column(db.String(20)) + + # Author + author_id = db.Column(db.Integer, db.ForeignKey('member.id')) + author = db.relationship('Member', backref=backref('polls'), + foreign_keys=author_id) + + # Title/question + title = db.Column(db.UnicodeText) + + # Start datetime + start = db.Column(db.DateTime, default=datetime.now()) + + # End datetime + end = db.Column(db.DateTime) + + # Choices + # We want a size-variable list of strings, or a dictionnary with + # key/values, depending on the poll type. + # As the data is likely to be adapted to the poll type, the PickleType + # seems to be appropriate. Same applies for PollAnswer. + choices = db.Column(db.PickleType) + + # Other fields populated automatically through relations: + # The list of answers (of type PollAnswer) + + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + 'polymorphic_on':type + } + + def __init__(self, author, title, choices, start=datetime.now(), end=datetime.now()): + self.author = author + self.title = title + self.choices = choices + self.start = start + self.end = end + + def delete(self): + """Deletes a poll and its answers""" + # TODO: move this out of class definition? + for answer in SpecialPrivilege.query.filter_by(poll_id=self.id).all(): + db.session.delete(answer) + db.session.commit() + + db.session.delete(self) + db.session.commit() + + # Common properties and methods + @property + def started(self): + """Returns whether the poll is open""" + return self.start <= datetime.now() + + @property + def ended(self): + """Returns whether the poll is closed""" + return self.end < datetime.now() + + def has_voted(self, user): + """Returns wheter the user has voted""" + # TODO: use ORM for this dirty request + return user in [a.author for a in self.answers] + + def can_vote(self, user): + """Returns true if the current user can vote. + More conditions may be added in the future""" + return user.is_authenticated + + # Poll-specific methods. Must be overrided per-poll definition + def vote(self, user, data): + """Return a PollAnswer object from specified user and data""" + return None + + @property + def results(self): + """Returns an easy-to-use object with answers of the poll.""" + return None + + +class PollAnswer(db.Model): + """An answer to a poll""" + + __tablename__ = 'pollanswer' + + # Unique ID + id = db.Column(db.Integer, primary_key=True) + + # Poll + poll_id = db.Column(db.Integer, db.ForeignKey('poll.id')) + poll = db.relationship('Poll', backref=backref('answers'), + foreign_keys=poll_id) + + # Author. Must be Member + author_id = db.Column(db.Integer, db.ForeignKey('member.id')) + author = db.relationship('Member', foreign_keys=author_id) + + # Choice(s) + answer = db.Column(db.PickleType) + + def __init__(self, poll, user, answer): + self.poll = poll + self.author = user + self.answer = answer diff --git a/app/models/polls/simple.py b/app/models/polls/simple.py new file mode 100644 index 0000000..04c285c --- /dev/null +++ b/app/models/polls/simple.py @@ -0,0 +1,49 @@ +from app import db +from app.models.poll import Poll, PollAnswer +from collections import Counter + +class SimplePoll(Poll): + """Poll with only one answer allowed""" + + __tablename__ = 'simplepoll' + + # Names of templates + template = 'simplepoll.html' + + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + } + + def __init__(self, author, title, choices, **kwargs): + choices = [Choice(i, t) for i, t in enumerate(choices)] + super().__init__(author, title, choices, **kwargs) + + # Mandatory methods + def vote(self, user, request): + try: + choice_id = int(request.form['pollanwsers']) + except (KeyError, ValueError): + return None + + answer = PollAnswer(self, user, choice_id) + return answer + + @property + def results(self): + values = {c: 0 for c in self.choices} + counter = Counter(values) + answers = [self.choice_from_id(a.answer) for a in self.answers] + counter.update(answers) + return counter + + # Custom method + def choice_from_id(self, id): + for c in self.choices: + if c.id == id: + return c + return None + +class Choice(): + def __init__(self, id, title): + self.id = id + self.title = title diff --git a/app/models/user.py b/app/models/user.py index 13c8ee2..9b77b09 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -121,6 +121,7 @@ class Member(User): # Other fields populated automatically through relations: # List of unseen notifications (of type Notification) + # Polls created by the member (of class Poll) def __init__(self, name, email, password): """Register a new user.""" diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 32449c0..28a675d 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,10 +1,16 @@ # Register routes here from app.routes import index, search, users, tools, development -from app.routes.account import login, account, notification +from app.routes.account import login, account, notification, polls from app.routes.admin import index, groups, account, trophies, forums, \ attachments, config, members from app.routes.forum import index, topic -from app.routes.programs import index +from app.routes.polls import vote from app.routes.posts import edit +from app.routes.programs import index from app.routes.api import markdown + +try: + from app.routes import test +except ImportError: + pass diff --git a/app/routes/account/account.py b/app/routes/account/account.py index 532c472..241f91a 100644 --- a/app/routes/account/account.py +++ b/app/routes/account/account.py @@ -144,6 +144,7 @@ def activate_account(token): ts = URLSafeTimedSerializer(app.config["SECRET_KEY"]) email = ts.loads(token, salt="email-confirm-key", max_age=86400) except Exception as e: + # TODO: add proper login print(f"Error: {e}") abort(404) diff --git a/app/routes/account/polls.py b/app/routes/account/polls.py new file mode 100644 index 0000000..665566f --- /dev/null +++ b/app/routes/account/polls.py @@ -0,0 +1,23 @@ +from app import app, db +from flask import abort, flash, redirect, request, url_for +from flask_login import current_user + +from app.models.polls.simple import SimplePoll +from app.forms.poll import PollForm +from app.utils.render import render + +@app.route("/compte/sondages", methods=['GET', 'POST']) +def account_polls(): + form = PollForm() + polls = current_user.polls + + if form.validate_on_submit(): + choices = list(filter(None, form.choices.data.split('\n'))) + p = SimplePoll(current_user, form.title.data, choices, + start=form.start.data, end=form.end.data) + db.session.add(p) + db.session.commit() + + flash(f"Le sondage {p.id} a été créé", "info") + + return render("account/polls.html", polls=polls, form=form) diff --git a/app/routes/api/markdown.py b/app/routes/api/markdown.py index 87a00e6..105111c 100644 --- a/app/routes/api/markdown.py +++ b/app/routes/api/markdown.py @@ -9,6 +9,5 @@ class API(): try: markdown = request.get_json()['text'] except BadRequestKeyError: - return "Dummy value" abort(400) return str(md(markdown)) diff --git a/app/routes/polls/vote.py b/app/routes/polls/vote.py new file mode 100644 index 0000000..7079bdb --- /dev/null +++ b/app/routes/polls/vote.py @@ -0,0 +1,41 @@ +from app import app, db +from flask import abort, flash, redirect, request, url_for +from flask_login import current_user + +from app.models.poll import Poll + +@app.route("/sondages//voter", methods=['POST']) +def poll_vote(poll_id): + poll = Poll.query.get(poll_id) + + if poll is None: + abort(404) + if not current_user.is_authenticated: + flash("Seuls les membres connectés peuvent voter", 'error') + abort(401) + if not poll.can_vote(current_user): + flash("Vous n'avez pas le droit de voter", 'error') + abort(403) + if poll.has_voted(current_user): + flash("Vous avez déjà voté", 'error') + abort(403) + if not poll.started: + flash("Le sondage n'a pas débuté", 'error') + abort(403) + if poll.ended: + flash("Le sondage est terminé", 'error') + abort(403) + + answer = poll.vote(current_user, request) + + if answer is None: + abort(400) + + db.session.add(answer) + db.session.commit() + + flash('Le vote a été pris en compte', 'info') + + if request.referrer: + return redirect(request.referrer) + return redirect(url_for('index')) diff --git a/app/templates/account/polls.html b/app/templates/account/polls.html new file mode 100644 index 0000000..bac4c4a --- /dev/null +++ b/app/templates/account/polls.html @@ -0,0 +1,53 @@ +{% extends "base/base.html" %} +{% import "widgets/poll.html" as poll_widget with context %} + +{% block title %} +

Gestion des sondages

+{% endblock %} + +{% block content %} +
+

Créer un sondage

+
+ {{ form.hidden_tag() }} +
+ {{ form.title.label }}
+ {{ form.title(size=32) }}
+ {% for error in form.title.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.choices.label }} + + {% for error in form.choices.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.start.label }} + {{ form.start() }} + {% for error in form.start.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.end.label }} + {{ form.end() }} + {% for error in form.end.errors %} + {{ error }} + {% endfor %} +
+
{{ form.submit(class_="bg-ok") }}
+
+
+ +
+

Mes sondages

+
+ {% for p in polls %} + {{ poll_widget.wpoll(p) }} + {% endfor %} +
+
+{% endblock %} diff --git a/app/templates/base/navbar/account.html b/app/templates/base/navbar/account.html index 000e0a7..de139f7 100644 --- a/app/templates/base/navbar/account.html +++ b/app/templates/base/navbar/account.html @@ -11,6 +11,11 @@ Notifications{{ " ({})".format(current_user.notifications|length) if current_user.notifications|length }} + + + + Sondages + diff --git a/app/templates/widgets/poll.html b/app/templates/widgets/poll.html new file mode 100644 index 0000000..fc18dad --- /dev/null +++ b/app/templates/widgets/poll.html @@ -0,0 +1,41 @@ +{% macro wpoll(poll) %} + +{% set n_answers = len(poll.answers) %} + +{% import "widgets/polls/"+poll.template as poll_template with context %} + +
+

{{ poll.title }}

+{# Poll has not begin #} + {% if not poll.started %} +

Le sondage ouvrira le {{ poll.start | date }}.

+ +{# Poll has ended: display results #} + {% elif poll.ended %} +
Ce sondage est terminé. Voici les résultats des {{ n_answers }} participation{{ n_answers | pluralize }}.
+ {{ poll_template.results(poll) }} + +{# Current user is a guest #} + {% elif not current_user.is_authenticated %} +

Seuls les membres peuvent voter

+ +{# Current user cannot vote #} + {% elif not poll.can_vote(current_user) %} +

Vous n'avez pas le droit de voter dans ce sondage. Désolé…

+ +{# Current user has already voted #} + {% elif poll.has_voted(current_user) %} +

Vous avez déjà voté. Revenez le {{ poll.end | date }} pour voir les résultats

+ +{# Current user can vote #} + {% else %} +
+ {{ poll_template.choices(poll) }} + + +
+ {% endif %} +
+{% endmacro %} + +{{ wpoll(poll) if poll }} diff --git a/app/templates/widgets/polls/defaultpoll.html b/app/templates/widgets/polls/defaultpoll.html new file mode 100644 index 0000000..51990c9 --- /dev/null +++ b/app/templates/widgets/polls/defaultpoll.html @@ -0,0 +1,7 @@ +{% macro choices(p) %} +
Default choices
+{% endmacro %} + +{% macro results(p) %} +
Default results.
+{% endmacro %} diff --git a/app/templates/widgets/polls/simplepoll.html b/app/templates/widgets/polls/simplepoll.html new file mode 100644 index 0000000..2cbb3e1 --- /dev/null +++ b/app/templates/widgets/polls/simplepoll.html @@ -0,0 +1,23 @@ +{% macro choices(poll) %} +
+{% for choice in poll.choices %} + +
+{% endfor %} +
+{% endmacro %} + +{% macro results(poll) %} + + {% for choice, votes in poll.results.most_common() %} + + + + + {% endfor %} +
+ + {{ votes / n_answers if n_answers else 0 }} % ({{ votes }}) + +
+{% endmacro %} diff --git a/app/utils/filters/markdown.py b/app/utils/filters/markdown.py index efddb79..f7455a9 100644 --- a/app/utils/filters/markdown.py +++ b/app/utils/filters/markdown.py @@ -5,6 +5,8 @@ from markdown.extensions.codehilite import CodeHiliteExtension from markdown.extensions.footnotes import FootnoteExtension from markdown.extensions.toc import TocExtension +from app.utils.markdown_extensions.pclinks import PCLinkExtension + @app.template_filter('md') def md(text): @@ -22,6 +24,7 @@ def md(text): CodeHiliteExtension(linenums=True, use_pygments=True), FootnoteExtension(UNIQUE_IDS=True), TocExtension(baselevel=2), + PCLinkExtension(), ] def escape(text): diff --git a/app/utils/markdown_extensions/pclinks.py b/app/utils/markdown_extensions/pclinks.py new file mode 100644 index 0000000..6a68376 --- /dev/null +++ b/app/utils/markdown_extensions/pclinks.py @@ -0,0 +1,102 @@ +''' +PClinks Extension for Python-Markdown +====================================== + +Converts [[type:id]] to relative links. + +Based on . + +Original code Copyright [Waylan Limberg](http://achinghead.com/). + +License: [BSD](https://opensource.org/licenses/bsd-license.php) +''' + +from markdown.extensions import Extension +from markdown.inlinepatterns import InlineProcessor +import xml.etree.ElementTree as etree +from flask import url_for, render_template +from app.utils.unicode_names import normalize +from app.models.poll import Poll +from app.models.user import Member + +class PCLinkExtension(Extension): + def __init__(self, **kwargs): + self.config = { + # 'base_url': ['/', 'String to append to beginning or URL.'], + # 'end_url': ['/', 'String to append to end of URL.'], + # 'html_class': ['pclink', 'CSS hook. Leave blank for none.'], + } + super().__init__(**kwargs) + + def extendMarkdown(self, md): + self.md = md + + # append to end of inline patterns + PCLINK_RE = r'\[\[([a-z]+): ?(\w+)\]\]' + pclinkPattern = PCLinksInlineProcessor(PCLINK_RE, self.getConfigs()) + pclinkPattern.md = md + md.inlinePatterns.register(pclinkPattern, 'pclink', 75) + + +class PCLinksInlineProcessor(InlineProcessor): + def __init__(self, pattern, config): + super().__init__(pattern) + self.config = config + self.handles = { + 'poll': handlePoll, + 'user': handleUser, + } + + def handleMatch(self, m, data): + link_type = m.group(1).strip() + if link_type in self.handles: + content_id = m.group(2).strip() + a = self.handles[link_type](content_id, data) + else: + a = '' + return a, m.start(0), m.end(0) + + +# pclinks are links defined as [[type:content_id]] +# To add a custom handle, create a function and add it to processor's handles +# A custom handle takes two arguments: +# - content_id: as defined +# - context: the block in which the link has been found +# It should return: +# - either a string, which will be html-escaped +# - either an xml.etree.ElementTree + +def handlePoll(content_id, context): + if not context.startswith("[[") or not context.endswith("]]"): + return "[Sondage invalide]" + try: + id = int(content_id) + except ValueError: + return "[ID du sondage invalide]" + + poll = Poll.query.get(content_id) + + if poll is None: + return "[Sondage non trouvé]" + + html = render_template('widgets/poll.html', poll=poll) + html = html.replace('\n', '') # Needed to avoid lots of
due to etree + return etree.fromstring(html) + +def handleUser(content_id, context): + try: + norm = normalize(content_id) + except ValueError: + return "[Nom d'utilisateur invalide]" + + member = Member.query.filter_by(norm=norm).first() + + if member is None: + return "[Utilisateur non trouvé]" + + a = etree.Element('a') + a.text = member.name + a.set('href', url_for('user_by_id', user_id=member.id)) + a.set('class', 'profile-link') + + return a diff --git a/migrations/versions/cfb91e6aa9fc_added_polls.py b/migrations/versions/cfb91e6aa9fc_added_polls.py new file mode 100644 index 0000000..6d6986d --- /dev/null +++ b/migrations/versions/cfb91e6aa9fc_added_polls.py @@ -0,0 +1,48 @@ +"""Added polls + +Revision ID: cfb91e6aa9fc +Revises: cd4868f312c5 +Create Date: 2021-02-19 21:08:25.065628 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'cfb91e6aa9fc' +down_revision = 'cd4868f312c5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('poll', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=20), nullable=True), + sa.Column('author_id', sa.Integer(), nullable=True), + sa.Column('title', sa.UnicodeText(), nullable=True), + sa.Column('start', sa.DateTime(), nullable=True), + sa.Column('end', sa.DateTime(), nullable=True), + sa.Column('choices', sa.PickleType(), nullable=True), + sa.ForeignKeyConstraint(['author_id'], ['member.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('pollanswer', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('poll_id', sa.Integer(), nullable=True), + sa.Column('author_id', sa.Integer(), nullable=True), + sa.Column('answer', sa.PickleType(), nullable=True), + sa.ForeignKeyConstraint(['author_id'], ['member.id'], ), + sa.ForeignKeyConstraint(['poll_id'], ['poll.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('pollanswer') + op.drop_table('poll') + # ### end Alembic commands ###