Merge branch 'polls' into dev

This commit is contained in:
Eldeberen 2021-02-20 17:52:59 +01:00
commit 5efcadb23e
Signed by untrusted user: Darks
GPG Key ID: 7515644268BE1433
19 changed files with 572 additions and 6 deletions

4
.gitignore vendored
View File

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

38
app/forms/poll.py Normal file
View File

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

View File

@ -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):
"""

123
app/models/poll.py Normal file
View File

@ -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:
# <answers> 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

View File

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

View File

@ -121,6 +121,7 @@ class Member(User):
# Other fields populated automatically through relations:
# <notifications> List of unseen notifications (of type Notification)
# <polls> Polls created by the member (of class Poll)
def __init__(self, name, email, password):
"""Register a new user."""

View File

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

View File

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

View File

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

View File

@ -9,6 +9,5 @@ class API():
try:
markdown = request.get_json()['text']
except BadRequestKeyError:
return "Dummy value"
abort(400)
return str(md(markdown))

41
app/routes/polls/vote.py Normal file
View File

@ -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/<int:poll_id>/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'))

View File

@ -0,0 +1,53 @@
{% extends "base/base.html" %}
{% import "widgets/poll.html" as poll_widget with context %}
{% block title %}
<h1>Gestion des sondages</h1>
{% endblock %}
{% block content %}
<section class="form">
<h1>Créer un sondage</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<div>
{{ form.title.label }}<br>
{{ form.title(size=32) }}<br>
{% for error in form.title.errors %}
<span style="color: red;">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.choices.label }}
<textarea id="{{ form.choices.name }}" name="{{ form.choices.name }}"></textarea>
{% for error in form.choices.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.start.label }}
{{ form.start() }}
{% for error in form.start.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.end.label }}
{{ form.end() }}
{% for error in form.end.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ form.submit(class_="bg-ok") }}</div>
</form>
</section>
<section>
<h1>Mes sondages</h1>
<div>
{% for p in polls %}
{{ poll_widget.wpoll(p) }}
{% endfor %}
</div>
</section>
{% endblock %}

View File

@ -11,6 +11,11 @@
<path fill="#ffffff" d="M20,2A2,2 0 0,1 22,4V16A2,2 0 0,1 20,18H6L2,22V4C2,2.89 2.9,2 4,2H20M4,4V17.17L5.17,16H20V4H4M6,7H18V9H6V7M6,11H15V13H6V11Z"></path>
</svg>Notifications{{ " ({})".format(current_user.notifications|length) if current_user.notifications|length }}
</a>
<a href="{{ url_for('account_polls') }}">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M9 17H7V10H9M13 17H11V7H13M17 17H15V13H17M19.5 19.1H4.5V5H19.5M19.5 3H4.5C3.4 3 2.5 3.9 2.5 5V19C2.5 20.1 3.4 21 4.5 21H19.5C20.6 21 21.5 20.1 21.5 19V5C21.5 3.9 20.6 3 19.5 3Z" />
</svg>Sondages
</a>
<a href="#">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M2,2V11C2,12 3,13 4,13H6.2C6.6,15 7.9,16.7 11,17V19.1C8.8,19.3 8,20.4 8,21.7V22H16V21.7C16,20.4 15.2,19.3 13,19.1V17C16.1,16.7 17.4,15 17.8,13H20C21,13 22,12 22,11V2H18C17.1,2 16,3 16,4H8C8,3 6.9,2 6,2H2M4,4H6V6L6,11H4V4M18,4H20V11H18V6L18,4M8,6H16V11.5C16,13.43 15.42,15 12,15C8.59,15 8,13.43 8,11.5V6Z"></path>

View File

@ -0,0 +1,41 @@
{% macro wpoll(poll) %}
{% set n_answers = len(poll.answers) %}
{% import "widgets/polls/"+poll.template as poll_template with context %}
<div class="poll">
<h3>{{ poll.title }}</h3>
{# Poll has not begin #}
{% if not poll.started %}
<p><i>Le sondage ouvrira le {{ poll.start | date }}.</i></p>
{# Poll has ended: display results #}
{% elif poll.ended %}
<div>Ce sondage est terminé. Voici les résultats des {{ n_answers }} participation{{ n_answers | pluralize }}.</div>
{{ poll_template.results(poll) }}
{# Current user is a guest #}
{% elif not current_user.is_authenticated %}
<p><i>Seuls les membres peuvent voter</i></p>
{# Current user cannot vote #}
{% elif not poll.can_vote(current_user) %}
<p><i>Vous n'avez pas le droit de voter dans ce sondage. Désolé…</i></p>
{# Current user has already voted #}
{% elif poll.has_voted(current_user) %}
<p><i>Vous avez déjà voté. Revenez le {{ poll.end | date }} pour voir les résultats</i></p>
{# Current user can vote #}
{% else %}
<form class="poll" action="{{ url_for('poll_vote', poll_id=poll.id) }}" method="post" enctype="multipart/form-data">
{{ poll_template.choices(poll) }}
<input type="submit" value="Envoyer">
<input id="csrf_token" name="csrf_token" type="hidden" value="{{ csrf_token() }}">
</form>
{% endif %}
</div>
{% endmacro %}
{{ wpoll(poll) if poll }}

View File

@ -0,0 +1,7 @@
{% macro choices(p) %}
<div>Default choices</div>
{% endmacro %}
{% macro results(p) %}
<div>Default results.</div>
{% endmacro %}

View File

@ -0,0 +1,23 @@
{% macro choices(poll) %}
<fieldset>
{% for choice in poll.choices %}
<input type="radio" id="{{ poll.id }}-{{ choice.id }}" name="pollanwsers" value="{{ choice.id }}" />
<label for="{{ poll.id }}-{{ choice.id }}">{{ choice.title }}</label><br>
{% endfor %}
</fieldset>
{% endmacro %}
{% macro results(poll) %}
<table>
{% for choice, votes in poll.results.most_common() %}
<tr>
<td><label for="{{ poll.id }}-{{ choice.id }}">{{ choice.title }}</label></td>
<td>
<progress id="{{ poll.id }}-{{ choice.id }}" value="{{ votes }}" max="{{ n_answers }}">
{{ votes / n_answers if n_answers else 0 }}% ({{ votes }})
</progress>
</td>
</tr>
{% endfor %}
</table>
{% endmacro %}

View File

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

View File

@ -0,0 +1,102 @@
'''
PClinks Extension for Python-Markdown
======================================
Converts [[type:id]] to relative links.
Based on <https://Python-Markdown.github.io/extensions/wikilinks>.
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 <br> 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

View File

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