diff --git a/.gitignore b/.gitignore index 5f599cb..b5993a7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ local_config.py ## Wiki wiki/ + +## Personal folder + +exclude/ diff --git a/app/__init__.py b/app/__init__.py index c6ba42c..7cc7839 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,12 +5,15 @@ from flask_login import LoginManager from config import Config import time - app = Flask(__name__) app.config.from_object(Config) + db = SQLAlchemy(app) migrate = Migrate(app, db) +from app.utils.converters import * +app.url_map.converters['topicslug'] = TopicSlugConverter +app.url_map.converters['forum'] = ForumConverter @app.before_request def request_time(): @@ -26,9 +29,14 @@ from app import models # IDK why this is here, but it works from app.models.comment import Comment from app.models.thread import Thread from app.models.forum import Forum +from app.models.topic import Topic from app.models.notification import Notification + from app.routes import index, search, users # To load routes at initialization from app.routes.account import login, account, notification -from app.routes.admin import index, groups, account, trophies +from app.routes.admin import index, groups, account, trophies, forums +from app.routes.forum import index + from app.utils import pluralize # To use pluralize into the templates +from app.utils import date from app.utils import is_title diff --git a/app/data/forums.yaml b/app/data/forums.yaml new file mode 100644 index 0000000..a0f6b1d --- /dev/null +++ b/app/data/forums.yaml @@ -0,0 +1,97 @@ +# This file is a list of forums to create when setting up Planète Casio. +# +# * Keys are used as URLs paths and for unique identification. +# * Prefixes represent the privilege category for a forum. Owning privileges +# with this prefix allows the user to post in this forum and all its +# sub-forum regardless of their settings ("forum-root-*" are hyper powerful). +# * For open forums, use the prefix "open". + +/: + name: Forum de Planète Casio + prefix: root + +# News + +/news: + name: Actualités + prefix: news + +/news/projects: + name: Actualités des projets + prefix: projectnews + descr: Nouveautés des projets de la communauté. + +/news/calc: + name: Actualités des constructeurs de calculatrices + prefix: calcnews + descr: Nouveautés CASIO, nouveaux modèles de calculatrices, mises à jour du + système ou nouveautés d'autres constructeurs. + +/news/events: + name: Événements organisés par Planète Casio + prefix: eventnews + descr: Tous les événements organisés par Planète Casio ou la communauté. + +/news/other: + name: Autres nouveautés + prefix: othernews + descr: Actualités non catégorisées. + +# Help + +/help: + name: Aide et questions + prefix: help + +/help/transfers: + name: Questions sur les tranferts + prefix: transferhelp + descr: Questions sur le transfert de fichiers et l'installation de programmes + sur la calculatrice. + +/help/calc: + name: Question sur l'utilisation des calculatrices + prefix: calchelp + descr: Questions sur l'utilisation des applications de la calculatrice, + paramètres, formats de fichiers... + +/help/prog: + name: Questions de programmation + prefix: proghelp + descr: Questions sur le développement et le debuggage de programmes. + +/help/other: + name: Autres questions + prefix: otherhelp + descr: Questions non catégorisées. + +# Projects + +/projects: + name: Forum des projets + prefix: projects + +/projects/games: + name: Projets de jeux + prefix: gameprojects + descr: Projets de jeux pour calculatrices, tous langages confondus et tous + modèles de calculatrices confondus. + +/projects/apps: + name: Projets d'applications, utilitaires, outils pour calculatrice + prefix: appprojects + descr: Projets d'applications (hors jeux) pour calculatrice, tous langages et + modèles confondus. + +/projects/tools: + name: Projets pour d'autres plateformes + prefix: toolprojetcs + descr: Tous les projets tournant sur ordinateur, téléphone, ou toute autre + plateforme que la calculatrice. + +# Discussion + +/discussion: + name: Discussion + prefix: discussion + descr: Sujets hors-sujet et discussion libre. diff --git a/app/forms/editor.py b/app/forms/editor.py new file mode 100644 index 0000000..8e7eb78 --- /dev/null +++ b/app/forms/editor.py @@ -0,0 +1,15 @@ +from flask_wtf import FlaskForm +from wtforms import TextAreaField + +class EditorForm(FlaskForm): + """ + A text editor with formatting buttons and help. A rendering macro is + defined in the template widgets/editor.html. + """ + + # TODO: How to set DataRequired() dynamically? + contents = TextAreaField() + + @property + def value(self): + return self.contents.data diff --git a/app/forms/forum.py b/app/forms/forum.py new file mode 100644 index 0000000..c5d2a80 --- /dev/null +++ b/app/forms/forum.py @@ -0,0 +1,9 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, FormField, SubmitField +from wtforms.validators import DataRequired +from app.forms.editor import EditorForm + +class TopicCreationForm(FlaskForm): + title = StringField('Nom du sujet', validators=[DataRequired()]) + message = FormField(EditorForm, 'Premier post') + submit = SubmitField('Créer le sujet') diff --git a/app/models/comment.py b/app/models/comment.py index 4b7fbd1..0889a21 100644 --- a/app/models/comment.py +++ b/app/models/comment.py @@ -5,22 +5,36 @@ class Comment(Post): __tablename__ = 'comment' __mapper_args__ = {'polymorphic_identity': __tablename__} - # ID of the associated Post object + # ID of the underlying Post object id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True) - # Standalone properties + # Comment contents text = db.Column(db.UnicodeText) - # Relations + # Parent thread thread_id = db.Column(db.Integer, db.ForeignKey('thread.id'), nullable=False) thread = db.relationship('Thread', backref='comments', foreign_keys=thread_id) - # attachement = db.relationship('Attachement', backref='comment') - def __init__(self, author, text, thread): - Post.__init__(author, text) - if isinstance(thread, Thread): - thread = thread.id - self.thread_id = thread + """ + Create a new Comment in a thread. + + Arguments: + author -- comment poster (User) + text -- contents (unicode string) + thread -- parent discussion thread (Thread) + """ + + Post.__init__(self, author) + self.thread = thread + + def edit(self, new_text): + """Edit a Comment's contents.""" + + self.text = new_text + self.touch() + + def __repr__(self): + return f'' diff --git a/app/models/forum.py b/app/models/forum.py index 2d053fe..c459375 100644 --- a/app/models/forum.py +++ b/app/models/forum.py @@ -5,22 +5,36 @@ class Forum(db.Model): __tablename__ = 'forum' id = db.Column(db.Integer, primary_key=True) - # Standalone properties + # Forum name, as displayed on the site (eg. "Problèmes de transfert") name = db.Column(db.Unicode(64)) - slug = db.Column(db.Unicode(64)) - description = db.Column(db.UnicodeText) + # Privilege prefix (sort of slug) for single-forum privileges (lowercase) + prefix = db.Column(db.Unicode(64)) + # Forum description, as displayed on the site + descr = db.Column(db.UnicodeText) + # Forum URL, for dynamic routes + url = db.Column(db.String(64)) # Relationships parent_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=True) parent = db.relationship('Forum', backref='sub_forums', remote_side=id, - lazy=True) + lazy=True, foreign_keys=parent_id) # Also [topics] which is provided by a backref from the Topic class - def __init__(self, name, description, priv_prefix): + def __init__(self, url, name, prefix, descr="", parent=None): + self.url = url self.name = name - self.description = description - self.priv_prefix = priv_prefix + self.descr = descr + self.prefix = prefix + + if isinstance(parent, str): + self.parent = Forum.query.filter_by(url=str).first() + else: + self.parent = parent def __repr__(self): return f'' + + def post_count(self): + """Number of posts in every topic of the forum, without subforums.""" + return sum(len(thread.comments) for thread in self.topics) diff --git a/app/models/post.py b/app/models/post.py index 0a033c0..eef1887 100644 --- a/app/models/post.py +++ b/app/models/post.py @@ -1,47 +1,58 @@ -from datetime import datetime from app import db from app.models.users import User - +from datetime import datetime class Post(db.Model): - """ Content a User can create and publish """ - __tablename__ = 'post' - id = db.Column(db.Integer, primary_key=True) + """Contents created and published by Users.""" + __tablename__ = 'post' + + # Unique Post ID for the whole site + id = db.Column(db.Integer, primary_key=True) + # Post type (polymorphic discriminator) type = db.Column(db.String(20)) + + # Creation and edition date + date_created = db.Column(db.DateTime) + date_modified = db.Column(db.DateTime) + + # Post author + author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + author = db.relationship('User', backref="posts",foreign_keys=author_id) + + # TODO: Post attachments? + __mapper_args__ = { 'polymorphic_identity': __tablename__, 'polymorphic_on': type } - # Standalone properties - date_created = db.Column(db.DateTime) - date_modified = db.Column(db.DateTime) + def __init__(self, author): + """ + Create a new Post. - # Relationships - author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + Arguments: + author -- post author (User) + """ - def __init__(self, author, text): - """ Create a Post """ - self.text = text - if isinstance(author, User): - author = author.id - self.author_id = author + self.author = author self.date_created = datetime.now() self.date_modified = datetime.now() - def update(self, text): - """ Update a post. Check whether the request sender has the right to do - this! """ - self.text = text + def touch(self): + """Touch a Post when it is edited.""" + self.date_modified = datetime.now() def change_ownership(self, new_author): - """ Change ownership of a post. Check whether the request sender has the - right to do this! """ - if isinstance(new_author, User): - new_author = new_author.id - self.author_id = new_author + """ + Change ownership of a Post. This is a privileged operation! + + Arguments: + new_author -- new post author (User) + """ + + self.author = new_author def __repr__(self): return f'' diff --git a/app/models/thread.py b/app/models/thread.py index 4f6bc4c..ab3eb74 100644 --- a/app/models/thread.py +++ b/app/models/thread.py @@ -1,35 +1,40 @@ from app import db -from app.models.post import Post -from app.models.comment import Comment -from config import V5Config -class Thread(Post): - """ Some thread, such as a topic, program, tutorial """ +class Thread(db.Model): + """Some thread, such as a topic, program, tutorial.""" - # Foreign Post object ID __tablename__ = 'thread' - id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True) - # Identify threads as a type of posts using the table name, and add a - # column to further discriminate types of threads - thread_type = db.Column(db.String(20)) - __mapper_args__ = { - 'polymorphic_identity': __tablename__, - 'polymorphic_on': thread_type - } + # Unique ID + id = db.Column(db.Integer, primary_key=True) - # Properties - title = db.Column(db.Unicode(V5Config.THREAD_NAME_MAXLEN)) - # Also a relation [comments] populated from the Comment class. - - # Relations + # Top comment top_comment_id = db.Column(db.Integer, db.ForeignKey('comment.id')) top_comment = db.relationship('Comment', foreign_keys=top_comment_id) - def __init__(self, author, text, title): - """ Create a Thread """ - Post.__init__(author, text) - self.title = title + # Also a relation [comments] populated from the Comment class. + + def __init__(self): + """ + Create a empty Thread. Normally threads are not meant to be empty, so + you should create a Comment with this thread as parent, then assign it + as top comment with a call to set_top_comment(). + """ + self.top_comment_id = None + + def set_top_comment(self, top_comment): + """ + Changes the top comment of the thread. The old top comment will become + visible in the flow of posts? + + Arguments: + top_comment -- new top comment, must belong to this thread + """ + + if top_comment not in self.comments: + raise Exception("Cannot set foreign comment as top thread comment") + + self.top_comment = top_comment def __repr__(self): - return f'' diff --git a/app/models/topic.py b/app/models/topic.py index 8e1f0fd..fe18b9d 100644 --- a/app/models/topic.py +++ b/app/models/topic.py @@ -1,22 +1,44 @@ from app import db -from app.models.thread import Thread +from app.models.post import Post +from config import V5Config - -class Topic(Thread): +class Topic(Post): __tablename__ = 'topic' - id = db.Column(db.Integer, db.ForeignKey('thread.id'), primary_key=True) __mapper_args__ = {'polymorphic_identity': __tablename__} - # Relationships + # ID of the underlying [Post] object + id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True) + + # Topic title + title = db.Column(db.Unicode(V5Config.THREAD_NAME_MAXLEN)) + + # Parent forum forum_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=False) forum = db.relationship('Forum', backref='topics',foreign_keys=forum_id) - def __init__(self, author, text, title, forum): - """ Create a Topic """ - Post.__init__(author, text, title) - if isinstance(forum, Forum): - forum = forum.id - self.forum_id = forum + # Associated thread + thread_id = db.Column(db.Integer,db.ForeignKey('thread.id'),nullable=False) + thread = db.relationship('Thread', foreign_keys=thread_id) + + # Number of views in the forum + views = db.Column(db.Integer) + + def __init__(self, forum, author, title, thread): + """ + Create a Topic. + + Arguments: + forum -- parent forum or sub-forum (Forum) + author -- post author (User) + title -- topic title (unicode string) + thread -- discussion thread attached to the topic (Thread) + """ + + Post.__init__(self, author) + self.title = title + self.views = 0 + self.thread = thread + self.forum = forum def __repr__(self): - return f'' diff --git a/app/models/users.py b/app/models/users.py index 21d65c2..55516dd 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -27,8 +27,7 @@ class User(UserMixin, db.Model): # User type (polymorphic discriminator) type = db.Column(db.String(30)) - # TODO: add good relation - posts = db.relationship('Post', backref="author", lazy=True) + # Also a [posts] relationship populated from the Post class. __mapper_args__ = { 'polymorphic_identity': __tablename__, diff --git a/app/routes/admin/forums.py b/app/routes/admin/forums.py new file mode 100644 index 0000000..000d872 --- /dev/null +++ b/app/routes/admin/forums.py @@ -0,0 +1,11 @@ +from app.utils.priv_required import priv_required +from app.utils.render import render +from app.models.forum import Forum +from app import app, db + +@app.route('/admin/forums', methods=['GET']) +@priv_required('access-admin-panel') +def adm_forums(): + main_forum = Forum.query.filter_by(parent=None).first() + + return render('admin/forums.html', main_forum=main_forum) diff --git a/app/routes/forum/index.py b/app/routes/forum/index.py new file mode 100644 index 0000000..1834164 --- /dev/null +++ b/app/routes/forum/index.py @@ -0,0 +1,42 @@ +from flask_login import current_user +from flask import request + +from app.utils.render import render +from app.forms.forum import TopicCreationForm +from app.models.forum import Forum +from app.models.topic import Topic +from app.models.thread import Thread +from app.models.comment import Comment +from app import app, db + +@app.route('/forum/') +def forum_index(): + main_forum = Forum.query.filter_by(parent=None).first() + return render('/forum/index.html', main_forum=main_forum) + +@app.route('/forum//', methods=['GET', 'POST']) +def forum_page(f): + form = TopicCreationForm() + + if form.validate_on_submit(): + # TODO: Check user privileges for this specific forum! + + # First create the thread, then the comment, then the topic + th = Thread() + db.session.add(th) + db.session.commit() + + c = Comment(current_user, form.message.value, th) + th.set_top_comment(c) + t = Topic(f, current_user, form.title.data, th) + + db.session.add(th) + db.session.add(c) + db.session.add(t) + db.session.commit() + + return render('/forum/forum.html', f=f, form=form) + +@app.route('/forum//') +def forum_topic(f, t): + return render('/forum/topic.html', f=f, t=t) diff --git a/app/routes/users.py b/app/routes/users.py index da7d2ca..92a5f6f 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -1,12 +1,14 @@ from flask import redirect, url_for from app import app from app.models.users import Member +from app.utils import unicode_names from app.utils.render import render @app.route('/user/') def user(username): - member = Member.query.filter_by(name=username).first_or_404() + norm = unicode_names.normalize(username) + member = Member.query.filter_by(norm=norm).first_or_404() return render('user.html', member=member) diff --git a/app/static/css/form.css b/app/static/css/form.css index 9747677..ddd0fd4 100644 --- a/app/static/css/form.css +++ b/app/static/css/form.css @@ -86,3 +86,10 @@ .trophies-panel p label { margin: 0; } + +/* Editor */ + +.editor textarea { + font-family: monospace; + height: 192px; +} diff --git a/app/static/css/navbar.css b/app/static/css/navbar.css index 2d333ed..78cf292 100644 --- a/app/static/css/navbar.css +++ b/app/static/css/navbar.css @@ -192,6 +192,7 @@ nav a:focus { margin: 8px 0; padding: 5px 2%; font-size: 14px; color: inherit; border: none; border-color: #141719; + border-radius: 2px; } #menu form input[type="text"]:focus, #menu form input[type="password"]:focus { @@ -201,7 +202,7 @@ nav a:focus { } #menu form input[type="submit"] { width: 100%; - margin: 16px 0 5px 0; + margin: 8px 0 5px 0; } #menu form label { font-size: 13px; color: #FFFFFF; opacity: .7; diff --git a/app/static/css/table.css b/app/static/css/table.css index 56016ac..c70652c 100644 --- a/app/static/css/table.css +++ b/app/static/css/table.css @@ -17,3 +17,38 @@ table th { table td { padding: 4px 6px; } + +/* Forum and sub-forum listings */ + +table.forumlist { + border-collapse: separate; + border-spacing: 0; + + margin: 16px 0; + width: 100%; +} + +/* table.forumlist th { + background: #d05950; + border-color: #b04940; + color: white; +} */ + +table.forumlist tr { + background: unset; +} +table.forumlist tr:nth-child(4n+2), +table.forumlist tr:nth-child(4n+3) { + background: rgba(0, 0, 0, .05); +} + +/* Topic table */ + +table.topiclist { + width: 90%; + margin: auto; +} +table.topiclist tr > *:nth-child(n+2) { + /* This matches all children except the first column */ + text-align: center; +} diff --git a/app/static/images/logo-small.png b/app/static/images/logo-small.png new file mode 100644 index 0000000..5410624 Binary files /dev/null and b/app/static/images/logo-small.png differ diff --git a/app/static/images/logo_noshadow-small.png b/app/static/images/logo_noshadow-small.png new file mode 100644 index 0000000..a78d1e6 Binary files /dev/null and b/app/static/images/logo_noshadow-small.png differ diff --git a/app/templates/admin/forums.html b/app/templates/admin/forums.html new file mode 100644 index 0000000..3d8848a --- /dev/null +++ b/app/templates/admin/forums.html @@ -0,0 +1,38 @@ +{% extends "base/base.html" %} + +{# This macro will allow us to perform recursive HTML generation #} +{% macro forumtree(f, level) %} + + {{ f.url }} + + {{ f.name }} + + {{ f.topics | length }} + {{ f.post_count() }} + + + {% for subf in f.sub_forums %} + {{ forumtree(subf, level+1) }} + {% endfor %} +{% endmacro %} + +{% block title %} +Panneau d'administration »

Forums

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

Cette page permet de gérer l'arbre des forums.

+ +

Arbre des forums

+ + {% if main_forum == None %} +

Il n'y a aucun forum.

+ {% else %} + + + {{ forumtree(main_forum, 0) }} +
URLNomSujetsMessages
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/admin/index.html b/app/templates/admin/index.html index 561f434..03f32f9 100644 --- a/app/templates/admin/index.html +++ b/app/templates/admin/index.html @@ -8,8 +8,9 @@

Pages générales du panneau d'administration :

{% endblock %} diff --git a/app/templates/base/navbar.html b/app/templates/base/navbar.html index 278b5d4..12f4445 100644 --- a/app/templates/base/navbar.html +++ b/app/templates/base/navbar.html @@ -1,7 +1,7 @@