diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..03527f2 --- /dev/null +++ b/.env.template @@ -0,0 +1,5 @@ +FLASK_PORT=5000 +FLASK_DEBUG=false +FLASK_HOST=localhost +FLASK_SECRET_KEY=ThisIsADevelopmentKey +DB_NAME=afpy.db diff --git a/.flake8 b/.flake8 index 2bcd70e..e886929 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,4 @@ [flake8] -max-line-length = 88 +max-line-length = 120 +exclude = venv/* +ignore = E402, W291, W503 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4b93988..8df24b2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,19 +8,20 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: Deploy - env: - deploy_key: ${{secrets.deploy_key}} - known_hosts: ${{secrets.known_hosts}} - run: | - mkdir -p ${HOME}/.ssh - printf "%s\n" "$known_hosts" > ${HOME}/.ssh/known_hosts - printf "%s\n" "$deploy_key" > ${HOME}/.ssh/id_ed25519 - chmod 600 ${HOME}/.ssh/id_ed25519 - eval $(ssh-agent) - ssh-add - rsync -a ./ afpy-org@deb.afpy.org:/home/afpy-org/src/ - ssh afpy-org@deb.afpy.org /home/afpy-org/venv/bin/python -m pip install --upgrade setuptools wheel pip - ssh afpy-org@deb.afpy.org /home/afpy-org/venv/bin/python -m pip install /home/afpy-org/src/[sentry] - ssh afpy-org@deb.afpy.org sudo systemctl restart afpy-org.service + - uses: actions/checkout@v1 + - name: Deploy + env: + deploy_key: ${{secrets.deploy_key}} + known_hosts: ${{secrets.known_hosts}} + run: | + mkdir -p ${HOME}/.ssh + printf "%s\n" "$known_hosts" > ${HOME}/.ssh/known_hosts + printf "%s\n" "$deploy_key" > ${HOME}/.ssh/id_ed25519 + chmod 600 ${HOME}/.ssh/id_ed25519 + eval $(ssh-agent) + ssh-add + rsync -a ./ afpy-org@deb.afpy.org:/home/afpy-org/src/ + ssh afpy-org@deb.afpy.org /home/afpy-org/venv/bin/python -m pip install --upgrade setuptools wheel pip + ssh afpy-org@deb.afpy.org /home/afpy-org/venv/bin/python -m pip install -r /home/afpy-org/src/requirements.txt + ssh afpy-org@deb.afpy.org /home/afpy-org/venv/bin/python -m pip install sentry + ssh afpy-org@deb.afpy.org sudo systemctl restart afpy-org.service diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..eca258d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +--- + +name: Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build_ubuntu: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: 3.7 # As on deb.afpy.org + - uses: actions/cache@v2 + with: + path: | + venv + key: ${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-dev.txt') }} + - name: Test + run: | + python --version + make install + make test diff --git a/.gitignore b/.gitignore index 5b20d15..4bd343a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,17 @@ .coverage .eggs .env +venv/ .pytest_cache __pycache__ afpy.egg-info static/css/*.map posts +images # PyCharm project files .idea out gen +*.db +images/ diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index aded0a5..0000000 --- a/.isort.cfg +++ /dev/null @@ -1,10 +0,0 @@ -[settings] -# Needed for black compatibility -multi_line_output=3 -include_trailing_comma=True -force_grid_wrap=0 -line_length=88 -combine_as_imports=True - -# If set, imports will be sorted within their section independent to the import_type. -force_sort_within_sections=True diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ecd69d7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.1.0 + hooks: + - id: flake8 + - repo: https://github.com/ambv/black + rev: stable + hooks: + - id: black + language_version: python3.8 + - repo: https://github.com/asottile/reorder_python_imports + rev: v1.9.0 + hooks: + - id: reorder-python-imports + args: [--py3-plus] diff --git a/Makefile b/Makefile index c473f77..83c7e24 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VENV = $(PWD)/.env +VENV = $(PWD)/venv PIP = $(VENV)/bin/pip PYTHON = $(VENV)/bin/python FLASK = $(VENV)/bin/flask @@ -9,26 +9,18 @@ all: install serve install: test -d $(VENV) || python3 -m venv $(VENV) - $(PIP) install --upgrade --no-cache pip setuptools -e .[test] + $(PIP) install --upgrade --no-cache pip setuptools -r requirements.txt -r requirements-dev.txt clean: - rm -fr dist rm -fr $(VENV) - rm -fr *.egg-info check-outdated: $(PIP) list --outdated --format=columns test: - $(PYTHON) -m pytest tests.py afpy.py --flake8 --isort --cov=afpy --cov=tests --cov-report=term-missing + $(PYTHON) -m pytest tests.py afpy/ --flake8 --black --cov=afpy --cov=tests --cov-report=term-missing serve: - env FLASK_APP=afpy.py FLASK_ENV=development $(FLASK) run + $(PYTHON) run.py -isort: - $(ISORT) -rc .isort.cfg afpy.py tests.py - -black: - $(VENV)/bin/black afpy.py tests.py - -.PHONY: all install clean check-outdated test serve isort black +.PHONY: all install clean check-outdated test serve diff --git a/README.md b/README.md index 37abec4..6829f4e 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,27 @@ Site Web de l'AFPy. ## Tester localement -Un `make install` suivi d'un `make serve` suffit pour tester -localement sans article. +Commencez par un `make install` + +Ensuite, `mv .env.template .env` en remplacant les valeurs +nécéssaires. + +Créez le répertoire des images: `images` à la racine du projet (ou +ailleurs, configuré via `IMAGE_PATHS` dans le `.env`). + +Puis un `make serve` suffit pour tester localement sans article. Si vous voulez des articles, lancez un `tar xjf posts.tar.bz2` -d'abord. - -Si vous avez votre propre venv, un `FLASK_APP=afpy.py -FLASK_ENV=development flask run` vous suffira. +d'abord, puis un `python xml2sql.py` ce qui remplira la DB +/!\ the default admin user is `admin:password ## Tester ```bash -pip install -e.[test] -make test # ou make test VENV="$VIRTUAL_ENV" pour utiliser votre venv. +make test ``` - ## Déployer Pour publier il suffit de `git push`, une action github s'occupe de la mise en prod. diff --git a/afpy.py b/afpy.py deleted file mode 100644 index 16e54b6..0000000 --- a/afpy.py +++ /dev/null @@ -1,340 +0,0 @@ -import email -import locale -import os -import time - -from dateutil.parser import parse -import docutils.core -import docutils.writers.html5_polyglot -import feedparser -from flask import ( - Flask, - abort, - jsonify, - redirect, - render_template, - request, - send_from_directory, - url_for, -) -from flask_caching import Cache -from itsdangerous import BadSignature, URLSafeSerializer - -import data_xml as data - -try: - import sentry_sdk - from sentry_sdk.integrations.flask import FlaskIntegration -except ImportError: - sentry_sdk = None - - -locale.setlocale(locale.LC_ALL, "fr_FR.UTF-8") - -cache = Cache(config={"CACHE_TYPE": "simple", "CACHE_DEFAULT_TIMEOUT": 600}) -signer = URLSafeSerializer(os.environ.get("SECRET", "changeme!")) -app = Flask(__name__) -cache.init_app(app) - - -PAGINATION = 12 - -PLANET = { - "Emplois AFPy": "https://www.afpy.org/feed/emplois/rss.xml", - "Nouvelles AFPy": "https://www.afpy.org/feed/actualites/rss.xml", - "Ascendances": "https://ascendances.wordpress.com/feed/", - "Code en Seine": "https://codeenseine.fr/feeds/all.atom.xml", - "Yaal": "https://www.yaal.fr/blog/feeds/all.atom.xml", -} - -MEETUPS = { - "amiens": "https://www.meetup.com/fr-FR/Python-Amiens", - "bruxelles": "https://www.meetup.com/fr-FR/" - "Belgium-Python-Meetup-aka-AperoPythonBe/", - "grenoble": "https://www.meetup.com/fr-FR/" "Groupe-dutilisateurs-Python-Grenoble/", - "lille": "https://www.meetup.com/fr-FR/Lille-py/", - "lyon": "https://www.meetup.com/fr-FR/Python-AFPY-Lyon/", - "nantes": "https://www.meetup.com/fr-FR/Nantes-Python-Meetup/", - "montpellier": "https://www.meetup.com/fr-FR/Meetup-Python-Montpellier/", -} - - -@app.errorhandler(404) -def page_not_found(e): - return render_template("404.html"), 404 - - -@app.route("/") -def index(): - posts = {} - for post in data.get_posts(data.POST_ACTUALITIES, end=4): - timestamp = post[data.TIMESTAMP] - posts[timestamp] = post - return render_template( - "index.html", body_id="index", name=data.POST_ACTUALITIES, posts=posts - ) - - -@app.route("/adhesions") -def adhesions(): - return render_template("adhesions.html", body_id="adhesions") - - -@app.route("/communaute") -def communaute(): - return render_template("communaute.html", body_id="communaute", meetups=MEETUPS) - - -@app.route("/irc") -def irc(): - return render_template("irc.html", body_id="irc") - - -@app.route("/docs/") -def rest(name): - try: - with open(f"templates/{name}.rst") as fd: - parts = docutils.core.publish_parts( - source=fd.read(), - writer=docutils.writers.html5_polyglot.Writer(), - settings_overrides={"initial_header_level": 2}, - ) - except FileNotFoundError: - abort(404) - return render_template( - "rst.html", body_id=name, html=parts["body"], title=parts["title"] - ) - - -@app.route("/post/edit/") -@app.route("/post/edit//token/") -def edit_post(name, token=None): - if name not in data.POSTS: - abort(404) - if token: - try: - timestamp = signer.loads(token) - except BadSignature: - abort(401) - post = data.get_post(name, timestamp) - if not post: - abort(404) - else: - post = {data.STATE: data.STATE_WAITING} - if post[data.STATE] == data.STATE_TRASHED: - return redirect(url_for("rest", name="already_trashed")) - return render_template( - "edit_post.html", body_id="edit-post", post=post, name=name, admin=False - ) - - -@app.route("/admin/post/edit//") -def edit_post_admin(name, timestamp): - if name not in data.POSTS: - abort(404) - post = data.get_post(name, timestamp) - if not post: - abort(404) - return render_template( - "edit_post.html", body_id="edit-post", post=post, name=name, admin=True - ) - - -@app.route("/post/edit/", methods=["post"]) -@app.route("/post/edit//token/", methods=["post"]) -def save_post(name, token=None): - if name not in data.POSTS: - abort(404) - if token: - try: - timestamp = signer.loads(token) - except BadSignature: - abort(401) - else: - timestamp = None - try: - post = data.save_post( - name, - timestamp=timestamp, - admin=False, - form=request.form, - files=request.files, - ) - except data.DataException as e: - abort(e.http_code) - edit_post_url = url_for( - "edit_post", name=name, token=signer.dumps(post["_timestamp"]) - ) - - if post[data.STATE] == data.STATE_TRASHED: - return redirect(url_for("rest", name="already_trashed")) - return render_template("confirmation.html", edit_post_url=edit_post_url) - - -@app.route("/admin/post/edit//", methods=["post"]) -def save_post_admin(name, timestamp): - if name not in data.POSTS: - abort(404) - try: - data.save_post( - name, - timestamp=timestamp, - admin=True, - form=request.form, - files=request.files, - ) - except data.DataException as e: - abort(e.http_code) - if "delete_image" in request.form: - return redirect(request.url) - return redirect(url_for("admin", name=name)) - - -@app.route("/posts/") -@app.route("/posts//page/") -def posts(name, page=1): - if name not in data.POSTS: - abort(404) - end = page * PAGINATION - start = end - PAGINATION - total_pages = (data.count_posts(name, data.STATE_PUBLISHED) // PAGINATION) + 1 - posts = {} - for post in data.get_posts(name, data.STATE_PUBLISHED, start=start, end=end): - timestamp = post[data.TIMESTAMP] - posts[timestamp] = post - return render_template( - "posts.html", - body_id=name, - posts=posts, - title=data.POSTS[name], - name=name, - page=page, - total_pages=total_pages, - ) - - -@app.route("/admin/posts/") -def admin(name): - if name not in data.POSTS: - abort(404) - posts = {} - for state in data.STATES: - posts[state] = state_posts = {} - for post in data.get_posts(name, state): - timestamp = post[data.TIMESTAMP] - state_posts[timestamp] = post - return render_template( - "admin.html", body_id="admin", posts=posts, title=data.POSTS[name], name=name - ) - - -@app.route("/posts//") -def post(name, timestamp): - if name not in data.POSTS: - abort(404) - post = data.get_post(name, timestamp, data.STATE_PUBLISHED) - if not post: - abort(404) - return render_template("post.html", body_id="post", post=post, name=name) - - -@app.route("/post_image/") -def post_image(path): - if path.count("/") != 3: - abort(404) - category, state, timestamp, name = path.split("/") - if category not in data.POSTS: - abort(404) - if state not in data.STATES: - abort(404) - return send_from_directory(data.root, path) - - -@app.route("/feed//rss.xml") -@cache.cached() -def feed(name): - if name not in data.POSTS: - abort(404) - entries = [] - for post in data.get_posts(name, data.STATE_PUBLISHED, end=50): - post["timestamp"] = post[data.TIMESTAMP] - post["link"] = url_for( - "post", name=name, timestamp=post["timestamp"], _external=True - ) - entries.append({"content": post}) - title = f"{data.POSTS[name]} AFPy.org" - return render_template( - "rss.xml", - entries=entries, - title=title, - description=title, - link=url_for("feed", name=name, _external=True), - ) - - -@app.route("/planet/") -@app.route("/planet/rss.xml") -@cache.cached() -def planet(): - entries = [] - for name, url in PLANET.items(): - for entry in feedparser.parse(url).entries: - if hasattr(entry, "updated_parsed"): - date = entry.updated_parsed - elif hasattr(entry, "published_parsed"): - date = entry.published_parsed - else: - date = time.time() - entry["timestamp"] = time.mktime(date) if date else time.time() - entries.append({"feed": name, "content": entry}) - entries.sort(reverse=True, key=lambda entry: entry["content"]["timestamp"]) - return render_template( - "rss.xml", - entries=entries, - title="Planet Python francophone", - description="Nouvelles autour de Python en français", - link=url_for("planet", _external=True), - ) - - -@app.route("/rss-jobs/RSS") -def jobs(): - return redirect("https://plone.afpy.org/rss-jobs/RSS", code=307) - - -@app.route("/status") -def status(): - stats = {} - for category in data.POSTS: - stats[category] = {} - for state in data.STATES: - stats[category][state] = data.count_posts(category, state) - - os_stats = os.statvfs(__file__) - stats["disk_free"] = os_stats.f_bavail * os_stats.f_frsize - stats["disk_total"] = os_stats.f_blocks * os_stats.f_frsize - stats["load_avg"] = os.getloadavg() - - return jsonify(stats) - - -@app.template_filter("rfc822_datetime") -def format_rfc822_datetime(timestamp): - return email.utils.formatdate(int(timestamp)) - - -@app.template_filter("parse_iso_datetime") -def parse_iso_datetime(iso_datetime, format_): - return parse(iso_datetime).strftime(format_) - - -if app.env == "development": # pragma: no cover - from sassutils.wsgi import SassMiddleware - - app.wsgi_app = SassMiddleware( - app.wsgi_app, {"afpy": ("sass", "static/css", "/static/css")} - ) - - -if sentry_sdk: - sentry_sdk.init(integrations=[FlaskIntegration()]) diff --git a/afpy/__init__.py b/afpy/__init__.py new file mode 100644 index 0000000..dd5e01a --- /dev/null +++ b/afpy/__init__.py @@ -0,0 +1,118 @@ +import email +import os +import os.path as op + +from flask import abort +from flask import Flask +from flask import render_template +from flask import request +from flask_admin import Admin +from flask_login import LoginManager +from flask_pagedown import PageDown +from peewee import DoesNotExist +from peewee import SqliteDatabase + +from afpy import config +from afpy.utils import markdown_to_html + +database = SqliteDatabase(database=config.DB_NAME) + +application = Flask(__name__) + +pagedown = PageDown(application) + +application.debug = config.FLASK_DEBUG +application.secret_key = config.FLASK_SECRET_KEY +application.config["FLASK_ADMIN_SWATCH"] = "lux" + + +# Initializes the login manager used for the admin +login_manager = LoginManager() +login_manager.init_app(application) + + +# Loads the user when a request is done to a protected page +@login_manager.user_loader +def load_user(uid): + try: + return AdminUser.get_by_id(uid) + except DoesNotExist: + return None + + +@application.errorhandler(404) +def page_not_found(e): + return render_template("pages/404.html"), 404 + + +from afpy.routes.home import home_bp +from afpy.routes.posts import posts_bp, post_render +from afpy.routes.jobs import jobs_bp, jobs_render +from afpy.routes.rss import rss_bp + +application.register_blueprint(home_bp) +application.register_blueprint(posts_bp) +application.register_blueprint(jobs_bp) +application.register_blueprint(rss_bp) + + +from afpy.models.AdminUser import AdminUser, AdminUser_Admin +from afpy.models.NewsEntry import NewsEntry, NewsEntry_Admin +from afpy.models.JobPost import JobPost, JobPost_Admin +from afpy.models.Slug import Slug, SlugAdmin + + +from afpy.routes.admin import AdminIndexView, NewAdminView, ChangePasswordView, ModerateView, CustomFileAdmin + +# Creates the Admin manager +admin = Admin( + application, + name="Afpy Admin", + template_mode="bootstrap4", + index_view=AdminIndexView(), + base_template="admin/admin_master.html", +) + +# Registers the views for each table +admin.add_view(AdminUser_Admin(AdminUser, category="Models")) +admin.add_view(NewsEntry_Admin(NewsEntry, category="Models")) +admin.add_view(JobPost_Admin(JobPost, category="Models")) +admin.add_view(SlugAdmin(Slug, category="Models")) +admin.add_view(CustomFileAdmin(config.IMAGES_PATH, "/images/", name="Images Files")) +admin.add_view(NewAdminView(name="New Admin", endpoint="register_admin")) +admin.add_view(ChangePasswordView(name="Change password", endpoint="change_password")) +admin.add_view(ModerateView(name="Moderate", endpoint="moderation")) + + +@application.template_filter("rfc822_datetime") +def format_rfc822_datetime(timestamp): + return email.utils.formatdate(int(timestamp)) + + +@application.template_filter("md2html") +def format_markdown2html(content): + return markdown_to_html(content) + + +@application.template_filter("slug_url") +def get_slug_url(item): + url_root = request.url_root + slug = item.slug.where(Slug.canonical == True).first() # noqa + if not slug: + if isinstance(item, JobPost): + return url_root[:-1] + "/emplois/" + str(item.id) + else: + return url_root[:-1] + "/actualites/" + str(item.id) + else: + return url_root[:-1] + slug.url + + +@application.route("/") +def slug_fallback(slug): + slug = Slug.get_or_none(url="/" + slug) + if not slug: + abort(404) + if slug.newsentry: + return post_render(slug.newsentry.id) + elif slug.jobpost: + return jobs_render(slug.jobpost.id) diff --git a/afpy/config.py b/afpy/config.py new file mode 100644 index 0000000..8b7462f --- /dev/null +++ b/afpy/config.py @@ -0,0 +1,25 @@ +import os +from dotenv import load_dotenv + +AFPY_ROOT = os.path.join(os.path.dirname(__file__), "../") # refers to application_top + +load_dotenv(os.path.join(AFPY_ROOT, ".env")) + + +def check_vars(): + for item in ["FLASK_DEBUG", "FLASK_HOST", "FLASK_SECRET_KEY", "DB_NAME"]: + if item not in os.environ: + raise EnvironmentError(f"{item} is not set in the server's environment or .env file. It is required.") + + +check_vars() +del check_vars + + +FLASK_PORT = os.getenv("FLASK_PORT") +FLASK_DEBUG = os.getenv("FLASK_DEBUG").lower() in ("true", "1") +FLASK_HOST = os.getenv("FLASK_HOST") +FLASK_SECRET_KEY = os.getenv("FLASK_SECRET_KEY") +DB_NAME = os.getenv("DB_NAME") +NEWS_PER_PAGE = 12 +IMAGES_PATH = os.getenv("IMAGES_PATH", f"{AFPY_ROOT}/images/") diff --git a/afpy/data/data.json b/afpy/data/data.json new file mode 100644 index 0000000..89429f8 --- /dev/null +++ b/afpy/data/data.json @@ -0,0 +1,18 @@ +{ + "meetups": { + "amiens": "https://www.meetup.com/fr-FR/Python-Amiens", + "bruxelles": "https://www.meetup.com/fr-FR/Belgium-Python-Meetup-aka-AperoPythonBe/", + "grenoble": "https://www.meetup.com/fr-FR/Groupe-dutilisateurs-Python-Grenoble/", + "lille": "https://www.meetup.com/fr-FR/Lille-py/", + "lyon": "https://www.meetup.com/fr-FR/Python-AFPY-Lyon/", + "nantes": "https://www.meetup.com/fr-FR/Nantes-Python-Meetup/", + "montpellier": "https://www.meetup.com/fr-FR/Meetup-Python-Montpellier/" + }, + "planet": { + "Emplois AFPy": "https://www.afpy.org/feed/emplois/rss.xml", + "Nouvelles AFPy": "https://www.afpy.org/feed/actualites/rss.xml", + "Ascendances": "https://ascendances.wordpress.com/feed/", + "Code en Seine": "https://codeenseine.fr/feeds/all.atom.xml", + "Yaal": "https://www.yaal.fr/blog/feeds/all.atom.xml" + } +} diff --git a/afpy/forms/JobPost.py b/afpy/forms/JobPost.py new file mode 100644 index 0000000..1931d3e --- /dev/null +++ b/afpy/forms/JobPost.py @@ -0,0 +1,23 @@ +from flask_pagedown.fields import PageDownField +from flask_wtf import FlaskForm +from wtforms import FileField +from wtforms import StringField +from wtforms import validators +from wtforms.validators import DataRequired + + +def validate_email_or_phone(form, field): + if not form.email.data and not form.phone.data: + raise validators.ValidationError("Must have phone or email") + + +class JobPostForm(FlaskForm): + title = StringField("Titre", validators=[DataRequired()]) + summary = StringField("Résumé (optionnel)") + content = PageDownField("Contenu de l'offre", validators=[DataRequired()]) + company = StringField("Entreprise", validators=[DataRequired()]) + location = StringField("Addresse", validators=[DataRequired()]) + contact_info = StringField("Personne à contacter", validators=[DataRequired()]) + email = StringField("Email", validators=[validate_email_or_phone]) + phone = StringField("Téléphone", validators=[validate_email_or_phone]) + image = FileField("Image (optionnel)") diff --git a/afpy/forms/NewsEntry.py b/afpy/forms/NewsEntry.py new file mode 100644 index 0000000..bc6997b --- /dev/null +++ b/afpy/forms/NewsEntry.py @@ -0,0 +1,14 @@ +from flask_pagedown.fields import PageDownField +from flask_wtf import FlaskForm +from wtforms import FileField +from wtforms import StringField +from wtforms.validators import DataRequired + + +class NewsEntryForm(FlaskForm): + title = StringField("Titre", validators=[DataRequired()]) + summary = StringField("Résumé (optionnel)") + content = PageDownField("Contenu de l'article", validators=[DataRequired()]) + author = StringField("Auteur", validators=[DataRequired()]) + author_email = StringField("Email (optionnel)") + image = FileField("Image (optionnel)") diff --git a/afpy/forms/__init__.py b/afpy/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/afpy/forms/auth.py b/afpy/forms/auth.py new file mode 100644 index 0000000..9ebcf51 --- /dev/null +++ b/afpy/forms/auth.py @@ -0,0 +1,56 @@ +from peewee import DoesNotExist +from werkzeug.security import check_password_hash +from wtforms import fields +from wtforms import form +from wtforms import validators + +from afpy.models.AdminUser import AdminUser + + +def validate_email_or_username(form, field): + try: + AdminUser.get(AdminUser.email == field.data) + except DoesNotExist: + try: + AdminUser.get(AdminUser.username == field.data) + except DoesNotExist: + raise validators.ValidationError("Unknown email or username") + + +def validate_password(form, field): + try: + user = AdminUser.get(AdminUser.email == field.data) + except DoesNotExist: + pass + else: + if not check_password_hash(user.password, form.password.data): + raise validators.ValidationError("Invalid password") + + +# Define login and registration forms (for flask-login) +class LoginForm(form.Form): + email_or_username = fields.StringField( + "Email or Username", validators=[validators.DataRequired(), validate_email_or_username] + ) + password = fields.PasswordField(validators=[validators.DataRequired(), validate_password]) + + +def validate_email_taken(form, field): + try: + AdminUser.get(AdminUser.email == field.data) + except DoesNotExist: + pass + else: + raise validators.ValidationError("Email taken") + + +class RegistrationForm(form.Form): + username = fields.StringField(validators=[validators.DataRequired()]) + email = fields.StringField(validators=[validators.email(), validators.input_required(), validate_email_taken]) + password = fields.PasswordField(validators=[validators.DataRequired()]) + + +class ChangePasswordForm(form.Form): + old_password = fields.PasswordField(validators=[validators.DataRequired()]) + new_password = fields.PasswordField(validators=[validators.DataRequired()]) + new_password_confirmation = fields.PasswordField(validators=[validators.DataRequired()]) diff --git a/afpy/models/AdminUser.py b/afpy/models/AdminUser.py new file mode 100644 index 0000000..554e4ec --- /dev/null +++ b/afpy/models/AdminUser.py @@ -0,0 +1,58 @@ +from datetime import datetime + +from flask_admin.contrib.peewee import ModelView +from flask_login import current_user +from peewee import CharField +from peewee import DateTimeField +from peewee import TextField +from werkzeug.security import generate_password_hash + +from afpy.models import BaseModel + + +class AdminUser(BaseModel): + username = CharField(null=False, help_text="Username of admin user", verbose_name="Username") + email = CharField(null=False, help_text="Email of admin user", verbose_name="Email") + password = TextField(null=False, help_text="Hashed password of admin user", verbose_name="Password") + dt_added = DateTimeField( + null=False, default=datetime.now, help_text="When was the admin user entry added", verbose_name="Datetime Added" + ) + + def get_id(self) -> int: + return int(self.id) + + @property + def is_authenticated(self): + return True + + @property + def is_active(self): + return True + + @property + def is_anonymous(self): + return False + + # Required for administrative interface + def __unicode__(self): + return self.username + + +class AdminUser_Admin(ModelView): + model_class = AdminUser + + def is_accessible(self): + return current_user.is_authenticated + + +if not AdminUser.table_exists(): + AdminUser.create_table() + try: + AdminUser.get_by_id(1) + except AdminUser.DoesNotExist: + AdminUser.create( + email="admin@admin.org", + username="admin", + password=generate_password_hash("password"), + dt_added=datetime.now(), + ).save() diff --git a/afpy/models/JobPost.py b/afpy/models/JobPost.py new file mode 100644 index 0000000..a939e55 --- /dev/null +++ b/afpy/models/JobPost.py @@ -0,0 +1,109 @@ +from datetime import datetime +from typing import Optional + +from flask_admin.contrib.peewee import ModelView +from flask_login import current_user +from peewee import CharField +from peewee import DateTimeField +from peewee import ForeignKeyField +from peewee import TextField + +from afpy.models import BaseModel +from afpy.models.AdminUser import AdminUser + + +class JobPost(BaseModel): + title = TextField(null=False, help_text="Title of the job post", verbose_name="Title") + summary = TextField(null=True, help_text="Summary of the job post", verbose_name="Summary") + content = TextField(null=False, help_text="Content of the job post", verbose_name="Content") + dt_submitted = DateTimeField( + null=False, + default=datetime.now, + help_text="When was the job post submitted", + verbose_name="Datetime Submitted", + index=True, + ) + dt_updated = DateTimeField( + null=False, default=datetime.now, help_text="When was the job post updated", verbose_name="Datetime Updated" + ) + dt_published = DateTimeField( + null=True, help_text="When was the job post published", verbose_name="Datetime Published" + ) + state = CharField( + null=False, + default="waiting", + choices=[("waiting", "waiting"), ("published", "published"), ("rejected", "rejected")], + help_text="Current state of the job post", + verbose_name="State", + ) + approved_by = ForeignKeyField( + AdminUser, + null=True, + default=None, + backref="adminuser", + help_text="Who approved the job post", + verbose_name="Approved by", + ) + + company = CharField(null=False, help_text="Company that posted the job", verbose_name="Company") + phone = CharField(null=True, help_text="Phone number to contact", verbose_name="Phone Number") + location = CharField(null=False, help_text="Where is the job located", verbose_name="Job Location") + email = CharField(null=True, help_text="Email to contact", verbose_name="Email Address") + contact_info = CharField(null=False, help_text="Person to contact", verbose_name="Contact info") + image_path = CharField(null=True, help_text="Image for the job post", verbose_name="Image Path in filesystem") + + @classmethod + def create( + cls, + title: str, + content: str, + company: str, + location: str, + contact_info: str, + email: Optional[str] = None, + phone: Optional[str] = None, + summary: Optional[str] = None, + dt_submitted: Optional[datetime] = None, + dt_updated: Optional[datetime] = None, + dt_published: Optional[datetime] = None, + state: str = "waiting", + approved_by: Optional[AdminUser] = None, + image_path: Optional[str] = None, + ): + if not dt_submitted: + dt_submitted = datetime.now() + if not dt_updated: + dt_updated = datetime.now() + + if not email and not phone: + raise ValueError("One of email or phone must be provided") + + new_job = super().create( + title=title, + content=content, + company=company, + location=location, + contact_info=contact_info, + email=email, + phone=phone, + summary=summary, + dt_submitted=dt_submitted, + dt_updated=dt_updated, + dt_published=dt_published, + state=state, + approved_by=approved_by, + image_path=image_path, + ) + new_job.save() + return new_job + + +class JobPost_Admin(ModelView): + model_class = JobPost + + def is_accessible(self): + return current_user.is_authenticated + + +if not JobPost.table_exists(): + JobPost.create_table() diff --git a/afpy/models/NewsEntry.py b/afpy/models/NewsEntry.py new file mode 100644 index 0000000..6be82bc --- /dev/null +++ b/afpy/models/NewsEntry.py @@ -0,0 +1,95 @@ +from datetime import datetime +from typing import Optional + +from flask_admin.contrib.peewee import ModelView +from flask_login import current_user +from peewee import CharField +from peewee import DateTimeField +from peewee import ForeignKeyField +from peewee import TextField + +from afpy.models import BaseModel +from afpy.models.AdminUser import AdminUser + + +class NewsEntry(BaseModel): + title = TextField(null=False, help_text="Title of the news entry", verbose_name="Title") + summary = TextField(null=True, help_text="Summary of the news entry", verbose_name="Summary") + content = TextField(null=False, help_text="Content of the news entry", verbose_name="Content") + dt_submitted = DateTimeField( + null=False, + default=datetime.now, + help_text="When was the news entry submitted", + verbose_name="Datetime Submitted", + index=True, + ) + dt_updated = DateTimeField( + null=False, default=datetime.now, help_text="When was the news entry updated", verbose_name="Datetime Updated" + ) + dt_published = DateTimeField( + null=True, help_text="When was the news entry published", verbose_name="Datetime Published" + ) + state = CharField( + null=False, + default="waiting", + choices=[("waiting", "waiting"), ("published", "published"), ("rejected", "rejected")], + help_text="Current state of the news entry", + verbose_name="State", + ) + approved_by = ForeignKeyField( + AdminUser, + null=True, + default=None, + backref="adminuser", + help_text="Who approved the news entry", + verbose_name="Approved by", + ) + author = CharField(null=False, default="Admin", help_text="Author of the news entry", verbose_name="Author") + author_email = CharField(null=True, help_text="Author email", verbose_name="Author Email") + image_path = CharField(null=True, help_text="Image for the news entry", verbose_name="Image Path in filesystem") + + @classmethod + def create( + cls, + title: str, + content: str, + author: str, + author_email: Optional[str] = None, + image_path: Optional[str] = None, + summary: Optional[str] = None, + dt_submitted: Optional[datetime] = None, + dt_updated: Optional[datetime] = None, + dt_published: Optional[datetime] = None, + state: str = "waiting", + approved_by: Optional[AdminUser] = None, + ): + if not dt_submitted: + dt_submitted = datetime.now() + if not dt_updated: + dt_updated = datetime.now() + new_article = super().create( + title=title, + content=content, + author=author, + author_email=author_email, + image_path=image_path, + summary=summary, + dt_submitted=dt_submitted, + dt_updated=dt_updated, + dt_published=dt_published, + state=state, + approved_by=approved_by, + ) + new_article.save() + return new_article + + +class NewsEntry_Admin(ModelView): + model_class = NewsEntry + + def is_accessible(self): + return current_user.is_authenticated + + +if not NewsEntry.table_exists(): + NewsEntry.create_table() diff --git a/afpy/models/Slug.py b/afpy/models/Slug.py new file mode 100644 index 0000000..5ef164f --- /dev/null +++ b/afpy/models/Slug.py @@ -0,0 +1,27 @@ +from flask_admin.contrib.peewee import ModelView +from flask_login import current_user +from peewee import BooleanField +from peewee import CharField +from peewee import ForeignKeyField + +from afpy.models import BaseModel +from afpy.models.JobPost import JobPost +from afpy.models.NewsEntry import NewsEntry + + +class Slug(BaseModel): + url = CharField(null=False, help_text="From URL", verbose_name="From URL", unique=True, index=True) + jobpost = ForeignKeyField(JobPost, backref="slug", null=True) + newsentry = ForeignKeyField(NewsEntry, backref="slug", null=True) + canonical = BooleanField(default=True) + + +class SlugAdmin(ModelView): + model_class = Slug + + def is_accessible(self): + return current_user.is_authenticated + + +if not Slug.table_exists(): + Slug.create_table() diff --git a/afpy/models/__init__.py b/afpy/models/__init__.py new file mode 100644 index 0000000..dbe4aee --- /dev/null +++ b/afpy/models/__init__.py @@ -0,0 +1,8 @@ +from peewee import Model + +from afpy import database + + +class BaseModel(Model): + class Meta: + database = database diff --git a/afpy/routes/__init__.py b/afpy/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/afpy/routes/admin.py b/afpy/routes/admin.py new file mode 100644 index 0000000..4a61221 --- /dev/null +++ b/afpy/routes/admin.py @@ -0,0 +1,158 @@ +from datetime import datetime + +import flask_admin as admin +from flask import flash +from flask import redirect +from flask import request +from flask import url_for +from flask_admin import expose +from flask_admin import helpers +from flask_admin.contrib.fileadmin import FileAdmin +from flask_login import current_user +from flask_login import login_user +from flask_login import logout_user +from peewee import DoesNotExist +from werkzeug.security import check_password_hash +from werkzeug.security import generate_password_hash + +from afpy.forms.auth import ChangePasswordForm +from afpy.forms.auth import LoginForm +from afpy.forms.auth import RegistrationForm +from afpy.models.AdminUser import AdminUser +from afpy.models.JobPost import JobPost +from afpy.models.NewsEntry import NewsEntry + + +# Create customized index view class that handles login & registration +class AdminIndexView(admin.AdminIndexView): + @expose("/") + def index(self): + if not current_user.is_authenticated: + return redirect(url_for(".login_view")) + return super(AdminIndexView, self).index() + + @expose("/login/", methods=("GET", "POST")) + def login_view(self): + # handle user login + form = LoginForm(request.form) + if helpers.validate_form_on_submit(form): + try: + user = AdminUser.get(AdminUser.email == form.email_or_username.data) + except DoesNotExist: + user = AdminUser.get(AdminUser.username == form.email_or_username.data) + if check_password_hash(user.password, form.password.data): + login_user(user) + else: + flash("Incorrect username or password") + + if current_user.is_authenticated: + return redirect(url_for("admin.index")) + self._template_args["form"] = form + return super(AdminIndexView, self).index() + + @expose("/logout/") + def logout_view(self): + logout_user() + return redirect(url_for("admin.index")) + + +class NewAdminView(admin.BaseView): + @expose("/", methods=("GET", "POST")) + def register_view(self): + if not current_user.is_authenticated: + return redirect(url_for("admin.index")) + form = RegistrationForm(request.form) + if helpers.validate_form_on_submit(form): + AdminUser.create( + email=form.email.data, + username=form.username.data, + password=generate_password_hash(form.password.data), + dt_added=datetime.now(), + ).save() + return redirect(url_for("admin.index")) + return self.render("admin/create_user.html", form=form) + + +class ChangePasswordView(admin.BaseView): + @expose("/", methods=("GET", "POST")) + def change_password_view(self): + if not current_user.is_authenticated: + return redirect(url_for("admin.index")) + form = ChangePasswordForm(request.form) + if helpers.validate_form_on_submit(form): + if not check_password_hash(current_user.password, form.old_password.data): + flash("Incorrect old password.") + return self.render("admin/change_password.html", form=form) + if not form.new_password.data == form.new_password_confirmation.data: + flash("Passwords don't match.") + return self.render("admin/change_password.html", form=form) + current_user.password = generate_password_hash(form.new_password.data) + current_user.save() + return redirect(url_for("admin.index")) + return self.render("admin/change_password.html", form=form) + + +class ModerateView(admin.BaseView): + @expose("/", methods=["GET"]) + def home_moderation(self): + if not current_user.is_authenticated: + return redirect(url_for("admin.index")) + return self.render("admin/moderation_home.html") + + @expose("/", methods=["GET"]) + def moderate_view(self, type): + if not current_user.is_authenticated: + return redirect(url_for("admin.index")) + if type == "jobs": + jobs = JobPost.select().where(JobPost.state == "waiting").order_by(JobPost.dt_submitted.desc()) + return self.render("admin/moderate_view.html", items=jobs, type=type) + elif type == "news": + news = NewsEntry.select().where(NewsEntry.state == "waiting").order_by(NewsEntry.dt_submitted.desc()) + return self.render("admin/moderate_view.html", items=news, type=type) + else: + flash("Wrong type") + return redirect(url_for("admin.index")) + + @expose("/preview//", methods=["GET"]) + def preview_item(self, type, id): + if not current_user.is_authenticated: + return redirect(url_for("admin.index")) + if type == "jobs": + job = JobPost.get_by_id(id) + return self.render("pages/job.html", job=job, preview=True) + elif type == "news": + news = NewsEntry.get_by_id(id) + return self.render("pages/post.html", post=news, preview=True) + else: + flash("Wrong type") + return redirect(url_for("admin.index")) + + @expose("/moderate/action///", methods=["GET"]) + def moderate_action(self, id, type, action): + if not current_user.is_authenticated: + return redirect(url_for("admin.index")) + if type == "jobs": + item = JobPost.get_by_id(id) + elif type == "news": + item = NewsEntry.get_by_id(id) + else: + flash("Wrong type") + return redirect(url_for("admin.index")) + if action == "approve": + item.state = "published" + item.approved_by = current_user.id + item.dt_published = datetime.now() + item.save() + elif action == "reject": + item.state = "rejected" + item.approved_by = current_user.id + item.save() + else: + flash("Wrong action type") + return redirect(url_for("admin.index")) + return redirect(url_for(".moderate_view", type=type)) + + +class CustomFileAdmin(FileAdmin): + def is_accessible(self): + return current_user.is_authenticated diff --git a/afpy/routes/home.py b/afpy/routes/home.py new file mode 100644 index 0000000..ade58de --- /dev/null +++ b/afpy/routes/home.py @@ -0,0 +1,77 @@ +import json + +from docutils.core import publish_parts +from docutils.writers import html5_polyglot +from flask import abort +from flask import Blueprint +from flask import render_template +from flask import send_from_directory +from peewee import DoesNotExist + +from afpy.models.NewsEntry import NewsEntry +from afpy import config + +home_bp = Blueprint("home", __name__) + + +@home_bp.route("/") +def home_page(): + all_news = NewsEntry.select().where(NewsEntry.state == "published").order_by(NewsEntry.dt_submitted.desc()).limit(4) + return render_template("pages/index.html", body_id="index", posts=all_news) + + +@home_bp.route("/communaute") +def community_page(): + with open(f"{config.AFPY_ROOT}/afpy/data/data.json", "r") as handle: + meetups = json.load(handle)["meetups"] + return render_template("pages/communaute.html", body_id="communaute", meetups=meetups) + + +@home_bp.route("/adherer") +def adhere_page(): + return render_template("pages/adhesions.html", body_id="adhesions") + + +@home_bp.route("/discussion") +def discussion_page(): + return render_template("pages/discussion.html", body_id="irc") + + +@home_bp.route("/docs/") +def render_rest(name): + try: + with open(f"{config.AFPY_ROOT}/afpy/templates/rest/{name}.rst") as fd: + parts = publish_parts( + source=fd.read(), writer=html5_polyglot.Writer(), settings_overrides={"initial_header_level": 2} + ) + except FileNotFoundError: + abort(404) + return render_template("pages/rst.html", body_id=name, html=parts["body"], title=parts["title"]) + + +@home_bp.route("/posts/") +def post_render(post_id: int): + try: + post = NewsEntry.get_by_id(post_id) + except DoesNotExist: + abort(404) + return render_template("pages/post.html", body_id="post", post=post, name=post.title) + + +@home_bp.route("/posts/page/") +def posts_page(current_page: int = 1): + total_pages = (NewsEntry.select().where(NewsEntry.state == "published").count() // config.NEWS_PER_PAGE) + 1 + posts = NewsEntry.select().where(NewsEntry.state == "published").paginate(current_page, config.NEWS_PER_PAGE) + return render_template( + "pages/posts.html", + body_id="posts", + posts=posts, + title="Actualités", + current_page=current_page, + total_pages=total_pages, + ) + + +@home_bp.route("/post_image/") +def get_image(path): + return send_from_directory(config.IMAGES_PATH, path) diff --git a/afpy/routes/jobs.py b/afpy/routes/jobs.py new file mode 100644 index 0000000..dd57651 --- /dev/null +++ b/afpy/routes/jobs.py @@ -0,0 +1,80 @@ +from flask import abort +from flask import Blueprint +from flask import redirect +from flask import render_template +from flask import request +from flask import url_for +from peewee import DoesNotExist +from werkzeug.utils import secure_filename + +from afpy.forms.JobPost import JobPostForm +from afpy.models.JobPost import JobPost +from afpy import config + + +jobs_bp = Blueprint("jobs", __name__) + + +@jobs_bp.route("/emplois/") +def jobs_render(post_id: int): + try: + job = JobPost.get_by_id(post_id) + except DoesNotExist: + abort(404) + return render_template("pages/job.html", body_id="emplois", job=job, name=job.title) + + +@jobs_bp.route("/emplois/page/") +def jobs_page(current_page: int = 1): + submitted = request.args.get("submitted", False) + total_pages = (JobPost.select().where(JobPost.state == "published").count() // config.NEWS_PER_PAGE) + 1 + jobs = ( + JobPost.select() + .where(JobPost.state == "published") + .order_by(JobPost.dt_submitted.desc()) + .paginate(current_page, config.NEWS_PER_PAGE) + ) + return render_template( + "pages/jobs.html", + body_id="emplois", + jobs=jobs, + title="Offres d'emploi", + current_page=current_page, + total_pages=total_pages, + submitted=submitted, + ) + + +@jobs_bp.route("/emplois/new", methods=["GET", "POST"]) +def new_job(): + form = JobPostForm() + if form.validate_on_submit(): + title = form.title.data + content = form.content.data + company = form.company.data + location = form.location.data + contact_info = form.contact_info.data + email = form.email.data + phone = form.phone.data + summary = form.summary.data + + new_job = JobPost.create( + title=title, + content=content, + company=company, + location=location, + contact_info=contact_info, + email=email, + phone=phone, + summary=summary, + ) + + if form.image.data: + extension = secure_filename(form.image.data.filename).split(".")[-1].lower() + filename = f"emplois.{new_job.id}.{extension}" + filepath = f"{config.IMAGES_PATH}/{filename}" + request.files[form.image.name].save(filepath) + new_job.image_path = filename + new_job.save() + return redirect(url_for("jobs.jobs_page", current_page=1, submitted=True)) + return render_template("pages/edit_job.html", form=form, post=None, body_id="edit-post") diff --git a/afpy/routes/posts.py b/afpy/routes/posts.py new file mode 100644 index 0000000..3da028a --- /dev/null +++ b/afpy/routes/posts.py @@ -0,0 +1,69 @@ +from flask import abort +from flask import Blueprint +from flask import redirect +from flask import render_template +from flask import request +from flask import url_for +from peewee import DoesNotExist +from werkzeug.utils import secure_filename + +from afpy.forms.NewsEntry import NewsEntryForm +from afpy.models.NewsEntry import NewsEntry +from afpy import config + +posts_bp = Blueprint("posts", __name__) + + +@posts_bp.route("/actualites/") +def post_render(post_id: int): + try: + post = NewsEntry.get_by_id(post_id) + except DoesNotExist: + abort(404) + return render_template("pages/post.html", body_id="actualites", post=post, name=post.title) + + +@posts_bp.route("/actualites/page/") +def posts_page(current_page: int = 1): + submitted = request.args.get("submitted", False) + total_pages = (NewsEntry.select().where(NewsEntry.state == "published").count() // config.NEWS_PER_PAGE) + 1 + posts = ( + NewsEntry.select() + .where(NewsEntry.state == "published") + .order_by(NewsEntry.dt_submitted.desc()) + .paginate(current_page, config.NEWS_PER_PAGE) + ) + return render_template( + "pages/posts.html", + body_id="actualites", + posts=posts, + title="Actualités", + current_page=current_page, + total_pages=total_pages, + submitted=submitted, + ) + + +@posts_bp.route("/actualites/new", methods=["GET", "POST"]) +def new_post(): + form = NewsEntryForm() + if form.validate_on_submit(): + title = form.title.data + summary = form.summary.data + content = form.content.data + author = form.author.data + author_email = form.author_email.data + + new_post = NewsEntry.create( + title=title, summary=summary, content=content, author=author, author_email=author_email + ) + + if form.image.data: + extension = secure_filename(form.image.data.filename).split(".")[-1].lower() + filename = f"emplois.{new_post.id}.{extension}" + filepath = f"{config.IMAGES_PATH}/{filename}" + request.files[form.image.name].save(filepath) + new_post.image_path = filename + new_post.save() + return redirect(url_for("posts.posts_page", current_page=1, submitted=True)) + return render_template("pages/edit_post.html", form=form, post=None, body_id="edit-post") diff --git a/afpy/routes/rss.py b/afpy/routes/rss.py new file mode 100644 index 0000000..6b48633 --- /dev/null +++ b/afpy/routes/rss.py @@ -0,0 +1,64 @@ +import json +import time + +import feedparser +from flask import abort +from flask import Blueprint +from flask import render_template +from flask import url_for + +from afpy.models.JobPost import JobPost +from afpy.models.NewsEntry import NewsEntry +from afpy import config + + +rss_bp = Blueprint("rss", __name__) + + +@rss_bp.route("/feed//rss.xml") +def feed_rss(type): + name = "" + entries = [] + if type == "emplois": + name = "Emplois" + entries = JobPost.select().where(JobPost.state == "published") + elif type == "actualites": + name = "Actualités" + entries = NewsEntry.select().where(NewsEntry.state == "published") + else: + abort(404) + title = f"{name} AFPy.org" + return render_template( + "pages/rss.xml", + entries=entries, + title=title, + description=title, + link=url_for("rss.feed_rss", type=type, _external=True), + type=type, + ) + + +@rss_bp.route("/planet/") +@rss_bp.route("/planet/rss.xml") +def planet_rss(): + entries = [] + with open(f"{config.AFPY_ROOT}/afpy/data/data.json", "r") as handle: + planet_items = json.load(handle)["planet"] + for name, url in planet_items.items(): + for entry in feedparser.parse(url).entries: + if hasattr(entry, "updated_parsed"): + date = entry.updated_parsed + elif hasattr(entry, "published_parsed"): + date = entry.published_parsed + else: + date = time.time() + entry["timestamp"] = time.mktime(date) if date else time.time() + entries.append({"feed": name, "content": entry}) + entries.sort(reverse=True, key=lambda entry: entry["content"]["timestamp"]) + return render_template( + "pages/planet_rss.xml", + entries=entries, + title="Planet Python francophone", + description="Nouvelles autour de Python en français", + link=url_for("rss.planet_rss", _external=True), + ) diff --git a/afpy/static/css/style.sass.css b/afpy/static/css/style.sass.css new file mode 100755 index 0000000..af77414 --- /dev/null +++ b/afpy/static/css/style.sass.css @@ -0,0 +1,254 @@ +@charset "UTF-8"; +@import url("https://fonts.googleapis.com/css?family=Hind:300,600,700"); +a { + color: #ffcd05; + font-weight: 700; + text-decoration: none; + transition: color 250ms; } +a:hover { + color: #ffd738; } +a.case-sensitive { + text-transform: none; } + +label { + display: block; + margin: 1em 0; + max-width: 40em; + width: 80%; } +label input, label select, label textarea { + background: #25252d; + border: 1px solid; + color: #eaeaea; + display: block; + padding: 0.2em; + width: 100%; } +label input:focus, label select:focus, label textarea:focus { + border-color: #ffcd05; } + +textarea { + height: 5em; } + +.button { + background: #2e5cfd; + border: 0; + color: #eaeaea; + cursor: pointer; + font-family: 'Hind', sans-serif; + outline: transparent; + padding: 1em 2em; + text-transform: uppercase; + transition: background 250ms; + width: auto; } +.button:hover { + background: #4770fd; } + +code { + background: #1d1e23; + border-bottom: 1px solid #ffcd05; + display: block; + padding: 2em; } + +table { + border-collapse: collapse; + margin: 1em 0; } +table thead, table tr:nth-child(even) { + background: #1d1e23; } +table td, table th { + padding: 0.3em 1em; } + +iframe { + background: #eaeaea; + border: 0; + height: 55em; + width: 100%; } + +body { + background: #25252d; + color: #eaeaea; + display: flex; + flex-direction: column; + font-family: 'Hind', sans-serif; + font-size: .9em; + font-weight: 300; + margin: 0; + min-height: 100vh; + padding: 0; } + +header { + background: #1d1e23; + box-sizing: border-box; + order: 1; + padding: 0 1em; } + +.wrapper { + width: 100%; + max-width: 1200px; + margin: 0 auto; + box-sizing: border-box; } + +.menu { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + padding: 0 1em; + min-height: 70px; } +.menu--footer { + background: #1d1e23; + box-sizing: border-box; + margin-top: 2em; + order: 4; } +.menu__toggle { + order: 2; + text-align: right; + align-self: flex-start; + padding-top: 1em; } +@media screen and (min-width: 840px) { + .menu__toggle { + display: none; } } +.menu__checkbox { + display: none; } +.menu__checkbox:checked + .menu__list { + max-height: 84px; + overflow: hidden; } +.menu__list { + flex: 1 1 100%; + display: flex; + flex-direction: column; + list-style: none; + margin: 0; + padding: 0; + max-height: 1000px; + transition: max-height .3s; } +@media screen and (min-width: 840px) { + .menu__list { + flex-direction: row; + justify-content: center; + align-items: center; } } +.menu__list a { + color: #eaeaea; + display: block; + font-weight: 600; + text-decoration: none; } +.menu__list .active a { + color: #ffcd05; } +.menu__item { + padding: 1em 0; + font-size: .8em; + text-transform: uppercase; + white-space: nowrap; } +@media screen and (min-width: 840px) { + .menu__item { + padding: 1em; } } +.menu__item--brand { + flex: 1 1 100%; + padding-left: 0; } +.menu__item .brand { + flex: 1 1 100%; + display: flex; + flex-direction: row; + align-items: center; + font-size: 1.5em; } +.menu__item .brand img { + padding-right: 1em; } +.menu__item .brand a { + font-weight: normal; + text-transform: none; + padding: 0; } + +main { + box-sizing: border-box; + flex-grow: 1; + margin: 1em auto 0; + max-width: 1200px; + order: 3; + padding: 0 1em; + width: 100%; } + +aside { + background: #1d1e23; + margin: 1em auto; + padding: 1em 2em; + width: 80%; } + +footer ul { + display: flex; + justify-content: center; + list-style: none; + padding: 0; } + +h1 { + color: #ffcd05; + font-weight: 300; + margin: 2em auto; + max-width: 1200px; } +h1::after { + background: #ffcd05; + content: ''; + display: block; + height: 3px; + width: 30px; } +h1 abbr { + display: block; } + +h2 { + font-weight: 400; } + +dd { + margin-left: 1em; } +dd p:before { + content: '→ '; + display: inline; } + +time { + display: block; } + +article img { + max-width: 100%; } + +#actualites main, #emplois main, #index-news { + box-sizing: border-box; + display: flex; + flex-wrap: wrap; } +#actualites main article, #emplois main article, #index-news article { + background: #31313b; + border: 1px solid #25252d; + box-sizing: border-box; + flex: 1 50%; + padding: 2em; + word-wrap: break-word; } +#actualites main article a, #emplois main article a, #index-news article a { + color: #ffcd05; + font-size: .8em; + font-weight: 700; + text-decoration: none; + text-transform: uppercase; + transition: color 250ms; } +#actualites main article a:hover, #emplois main article a:hover, #index-news article a:hover { + color: #ffd738; } +#actualites main article h2, #emplois main article h2, #index-news article h2 { + flex: 1 100%; } +#actualites main article article, #emplois main article article, #index-news article article { + background: #31313b; + border: 1px solid #25252d; + box-sizing: border-box; + flex: 1 50%; + padding: 2em; } +#actualites main article article h2, #emplois main article article h2, #index-news article article h2 { + font-size: 1.2em; + font-weight: 600; } + +@media screen and (max-width: 640px) { + #actualites main article, #emplois main article, #index-news article { + flex: 1 100%; + width: 100%; } } + +.pagedown-row { + display: flex; +} + +.pagedown-column { + flex: 50%; +} + +/*# sourceMappingURL=../static/css/style.sass.css.map */ \ No newline at end of file diff --git a/static/images/favicon.ico b/afpy/static/images/favicon.ico old mode 100644 new mode 100755 similarity index 100% rename from static/images/favicon.ico rename to afpy/static/images/favicon.ico diff --git a/static/images/logo.svg b/afpy/static/images/logo.svg old mode 100644 new mode 100755 similarity index 100% rename from static/images/logo.svg rename to afpy/static/images/logo.svg diff --git a/afpy/templates/_parts/base.jinja2 b/afpy/templates/_parts/base.jinja2 new file mode 100644 index 0000000..c59a402 --- /dev/null +++ b/afpy/templates/_parts/base.jinja2 @@ -0,0 +1,25 @@ + + +{% include "_parts/html_head.jinja2" %} + + + +
+
+ {% block header %} + {% endblock header %} +
+
+ +{% include "_parts/nav_menu.jinja2" %} + +
+ {% include "_parts/flashes.jinja2" %} + {% block main %} + {% endblock main %} +
+ +{% include "_parts/html_footer.jinja2" %} + + + diff --git a/afpy/templates/_parts/flashes.jinja2 b/afpy/templates/_parts/flashes.jinja2 new file mode 100644 index 0000000..b0bc322 --- /dev/null +++ b/afpy/templates/_parts/flashes.jinja2 @@ -0,0 +1,12 @@ +{% block flashes %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +{% endblock %} diff --git a/afpy/templates/_parts/html_footer.jinja2 b/afpy/templates/_parts/html_footer.jinja2 new file mode 100644 index 0000000..5b488e8 --- /dev/null +++ b/afpy/templates/_parts/html_footer.jinja2 @@ -0,0 +1,17 @@ +{% block html_footer %} +
+ {% set navigation_bar = [ + (url_for('home.render_rest', name='contact'), 'Contact'), + (url_for('home.render_rest', name='charte'), 'Charte'), + (url_for('home.render_rest', name='legal'), 'Mentions légales'), + (url_for('home.render_rest', name='rss'), 'Flux RSS'), + ('https://twitter.com/asso_python_fr', 'Twitter'), + (url_for('admin.index', name='Admin'), 'Admin'), + ] -%} + +
+{% endblock %} diff --git a/afpy/templates/_parts/html_head.jinja2 b/afpy/templates/_parts/html_head.jinja2 new file mode 100644 index 0000000..11dcee5 --- /dev/null +++ b/afpy/templates/_parts/html_head.jinja2 @@ -0,0 +1,10 @@ +{% block html_head %} + + + + AFPY - Le site web de l'Association Francophone Python + + + {{ pagedown.include_pagedown() }} + +{% endblock %} diff --git a/afpy/templates/_parts/nav_menu.jinja2 b/afpy/templates/_parts/nav_menu.jinja2 new file mode 100644 index 0000000..e9157e6 --- /dev/null +++ b/afpy/templates/_parts/nav_menu.jinja2 @@ -0,0 +1,28 @@ +{% block nav_menu %} + +{% endblock %} diff --git a/afpy/templates/admin/admin_master.html b/afpy/templates/admin/admin_master.html new file mode 100644 index 0000000..9b0ca79 --- /dev/null +++ b/afpy/templates/admin/admin_master.html @@ -0,0 +1,14 @@ +{% extends 'admin/base.html' %} + +{% block access_control %} +{% if current_user.is_authenticated %} + +{% endif %} +{% endblock %} diff --git a/afpy/templates/admin/change_password.html b/afpy/templates/admin/change_password.html new file mode 100644 index 0000000..8359406 --- /dev/null +++ b/afpy/templates/admin/change_password.html @@ -0,0 +1,46 @@ +{% extends 'admin/master.html' %} +{% block body %} + {{ super() }} + {% include "_parts/flashes.jinja2" %} +
+
+
+
+
+ Change your password +
+
+ +
+ {{ form.old_password }} +

Your old password

+
+
+ +
+ +
+ {{ form.new_password }} +

Your new password

+
+
+ +
+ +
+ {{ form.new_password_confirmation }} +

Confirm your new password

+
+
+ +
+
+ +
+
+
+
+
+ +
+{% endblock body %} diff --git a/afpy/templates/admin/create_user.html b/afpy/templates/admin/create_user.html new file mode 100644 index 0000000..ff99dfc --- /dev/null +++ b/afpy/templates/admin/create_user.html @@ -0,0 +1,50 @@ +{% extends 'admin/master.html' %} +{% block body %} + {{ super() }} + {% include "_parts/flashes.jinja2" %} +
+
+
+
+
+ Register +
+
+ + +
+ {{ form.username }} +

Username can contain any letters or numbers, without spaces

+
+
+ +
+ + +
+ {{ form.email }} +

Please provide your E-mail

+
+
+ +
+ + +
+ {{ form.password }} +

Password should be at least 4 characters

+
+
+ +
+ +
+ +
+
+
+
+
+ +
+{% endblock body %} diff --git a/afpy/templates/admin/index.html b/afpy/templates/admin/index.html new file mode 100644 index 0000000..1e84d89 --- /dev/null +++ b/afpy/templates/admin/index.html @@ -0,0 +1,36 @@ +{% extends 'admin/master.html' %} +{% block body %} + {{ super() }} +
+ {% include "_parts/flashes.jinja2" %} +
+ {% if current_user.is_authenticated %} +

AFPy Backend Admin

+

+ Bienvenue dans le backoffice de l'AFPy. Vous avez accès à la création, modification et suppression d'articles, jobs et admins, ainsi qu'aux tables.
+ Ne touchez à rien si vous ne savez pas ce que vous faites. +

+ {% else %} +
+ {{ form.hidden_tag() if form.hidden_tag }} + {% for f in form if f.type != 'CSRFTokenField' %} +
+ {{ f.label }} + {{ f }} + {% if f.errors %} +
    + {% for e in f.errors %} +
  • {{ e }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endfor %} + +
+ {{ link | safe }} + {% endif %} +
+ +
+{% endblock body %} diff --git a/afpy/templates/admin/moderate_view.html b/afpy/templates/admin/moderate_view.html new file mode 100644 index 0000000..11ad9da --- /dev/null +++ b/afpy/templates/admin/moderate_view.html @@ -0,0 +1,35 @@ +{% extends 'admin/master.html' %} +{% block body %} + {{ super() }} + {% include "_parts/flashes.jinja2" %} +
+
+ +
+ {% if not items %} +

Nothing to moderate.

+ {% else %} + {% for item in items %} +
+

{{ item.title }}

+ {% if item.image_path %} +

An image exists but has not been displayed

+ {% endif %} + {% if item.summary %} +

{{ item.summary }}

+ {% endif %} + {% if type == "jobs" %} +

Preview

+

Edit

+

Approve Reject

+ {% else %} +

Preview

+

Edit

+

Approve Reject

+ {% endif %} +
+ {% endfor %} + {% endif %} +
+
+{% endblock body %} diff --git a/afpy/templates/admin/moderation_home.html b/afpy/templates/admin/moderation_home.html new file mode 100644 index 0000000..d90f190 --- /dev/null +++ b/afpy/templates/admin/moderation_home.html @@ -0,0 +1,18 @@ +{% extends 'admin/master.html' %} +{% block body %} + {{ super() }} +
+ {% include "_parts/flashes.jinja2" %} +
+ {% if current_user.is_authenticated %} + + + {% endif %} +
+ +
+{% endblock body %} diff --git a/afpy/templates/confirmation.html b/afpy/templates/confirmation.html new file mode 100755 index 0000000..fffc303 --- /dev/null +++ b/afpy/templates/confirmation.html @@ -0,0 +1,31 @@ +{% extends '_layout.jinja2' %} + +{% block header %} +

Confirmation de l'enregistrement de l'article

+{% endblock header %} + +{% block main %} + +

Merci de votre participation

+ +

+ Votre article a bien été enregistré. Il sera mis en ligne après acceptation + de l'un des modérateurs. +

+

+ En attendant, vous pouvez toujours la modifier en utilisant le lien : + {{ edit_post_url }}. + Attention à conserver celui-ci secret ! +

+ + +

Demande d'informations complémentaires

+ +

+ Si vous avez besoin d'informations complémentaires concernant votre article, + ou si vous ne comprenez pas pourquoi votre article n'apparaît pas encore en + ligne plusieurs jours après avoir été posté, n'hésitez pas à nous contacter + sur la page Discussion. +

+ +{% endblock main %} diff --git a/afpy/templates/pages/404.html b/afpy/templates/pages/404.html new file mode 100755 index 0000000..92c0617 --- /dev/null +++ b/afpy/templates/pages/404.html @@ -0,0 +1,9 @@ +{% extends "_parts/base.jinja2" %} + +{% block header %} +

404

+{% endblock header %} + +{% block main %} +

Ce n'est pas la page que vous cherchez.

+{% endblock main %} diff --git a/afpy/templates/pages/adhesions.html b/afpy/templates/pages/adhesions.html new file mode 100755 index 0000000..1ce4684 --- /dev/null +++ b/afpy/templates/pages/adhesions.html @@ -0,0 +1,11 @@ +{% extends '_parts/base.jinja2' %} + +{% block header %} +

Adhésions

+{% endblock header %} + +{% block main %} +

Adhérez à l'AFPy

+ +

Si le widget ne fonctionne pas, essayez cette page : https://www.helloasso.com/associations/afpy/adhesions/adhesion-2021-a-l-afpy

+{% endblock main %} diff --git a/afpy/templates/pages/communaute.html b/afpy/templates/pages/communaute.html new file mode 100755 index 0000000..0f9a05b --- /dev/null +++ b/afpy/templates/pages/communaute.html @@ -0,0 +1,48 @@ +{% extends "_parts/base.jinja2" %} + +{% block header %} +

Communauté

+{% endblock header %} + +{% block main %} +

Forum de discussion

+

+ Afin d'échanger avec la communauté, un forum de discussion est disponible et + traite de tous les sujets autour de Python. +

+

+ Forum +

+ +

Rencontres

+

+ Afin de partager autour du langage Python, de ses pratiques, de sa technique et de son écosystème, + des évènements sont organisés régulièrement dans divers lieux. +

+ + +

PyConFr

+

+ La PyConFr est un évènement organisé chaque année depuis 10+ ans par l'AFPy. + Cette conférence est gratuite, entièrement organisée par des bénévoles et + regroupe développeu·ses·rs, chercheu·ses·rs, étudiant·e·s et amat·rices·eurs + autour d'une même passion pour le langage de programmation Python. +

+

+ PyConFr +

+ +

April

+

+ Pionnière du logiciel libre en France, l'April est depuis 1996 un acteur majeur de la démocratisation + et de la diffusion du logiciel libre et des standards ouverts auprès du grand public, + des professionnels et des institutions dans l'espace francophone. +

+

+ April +

+{% endblock main %} diff --git a/afpy/templates/pages/discussion.html b/afpy/templates/pages/discussion.html new file mode 100755 index 0000000..6834b02 --- /dev/null +++ b/afpy/templates/pages/discussion.html @@ -0,0 +1,44 @@ +{% extends "_parts/base.jinja2" %} + +{% block header %} +

Discussion

+{% endblock header %} + +{% block main %} +

Depuis cette page web vous pouvez

+ +
    +
  • venir discuter avec l'association sur le canal #afpy
  • +
  • parler Python en français sur le canal #python-fr
  • +
  • accéder aux Mailing Lists
  • +
+ +

Bon à savoir

+ +

+ IRC (Internet Relay Chat) permet d'utiliser plusieurs canaux de discussion en simultané. Si vous vous trouvez sur #afpy et souhaitez rejoindre #python-fr, rien de plus simple, tapez : + /join #python-fr +

+ +

+ Si vous souhaitez changer de surnom après connexion : + /nick nouveaunom +

+ +

Discuter avec l'AFPy (organisation de la communauté)

+ + + +

+ Vous pouvez aussi accéder au T'chat via un client IRC : irc://irc.freenode.net/afpy. + Nous stockons les archives IRC du canal #afpy. +

+ +

Discuter autour de Python

+ + + +

+ Vous pouvez aussi accéder au T'chat via un client IRC : irc://irc.libera.chat/python-fr. +

+{% endblock main %} diff --git a/afpy/templates/pages/edit_job.html b/afpy/templates/pages/edit_job.html new file mode 100644 index 0000000..a6e2aa3 --- /dev/null +++ b/afpy/templates/pages/edit_job.html @@ -0,0 +1,61 @@ +{% extends '_parts/base.jinja2' %} + +{% block header %} +

+ {% if post %} + Modification d'un job + {% else %} + Création d'un job + {% endif %} +

+{% endblock header %} + +{% block main %} + + +
+
+ {{ form.hidden_tag() }} + {% for field, errors in form.errors.items() %} +
+ {{ form[field].label }}: {{ ', '.join(errors) }} +
+ {% endfor %} + + + + + + + + + + +
+
+{% endblock main %} diff --git a/afpy/templates/pages/edit_post.html b/afpy/templates/pages/edit_post.html new file mode 100755 index 0000000..07c0bba --- /dev/null +++ b/afpy/templates/pages/edit_post.html @@ -0,0 +1,57 @@ +{% extends '_parts/base.jinja2' %} + +{% block header %} +

+ {% if post %} + Modification d'un article + {% else %} + Création d'un article + {% endif %} +

+{% endblock header %} + +{% block main %} + + +
+
+ {{ form.hidden_tag() }} + {% for field, errors in form.errors.items() %} +
+ {{ form[field].label }}: {{ ', '.join(errors) }} +
+ {% endfor %} + + + + + + + +
+

+ L'adresse e-mail n'est pas rendue publique, elle est uniquement + utilisée par les modérateurs pour vous contacter si nécessaire. +

+
+{% endblock main %} diff --git a/afpy/templates/pages/index.html b/afpy/templates/pages/index.html new file mode 100755 index 0000000..8e7fa6a --- /dev/null +++ b/afpy/templates/pages/index.html @@ -0,0 +1,38 @@ +{% extends '_parts/base.jinja2' %} + +{% block header %} +

AFPy Association Francophone Python

+{% endblock header %} + +{% block main %} +

AFPy

+

+ Créée en décembre 2004, l'AFPy (Association Francophone Python) a pour but de promouvoir le langage Python, que ce soit auprès d'un public averti ou débutant. + Pour ce faire, des évènements sont organisés régulièrement au niveau local et d'autres évènements à un niveau plus général. +

+ +

Adhérer

+

+ Il est possible de soutenir le développement de l'AFPy en cotisant ou en effectuant un don. +

+
+ +
+ +

Actualités

+
+ {% for post in posts %} +
+

{{ post.title }}

+ + {% if post.image_path %} + {{ post.title }} + {% endif %} + {{ post.summary | safe }} +

Lire la suite…

+
+ {% endfor %} +
+{% endblock main %} diff --git a/afpy/templates/pages/job.html b/afpy/templates/pages/job.html new file mode 100755 index 0000000..58a252e --- /dev/null +++ b/afpy/templates/pages/job.html @@ -0,0 +1,51 @@ +{% extends '_parts/base.jinja2' %} + +{% block header %} +

{{ job.title }}

+{% endblock header %} + +{% block main %} + + {% if preview %} + Retour à l'interface d'administration + {#

Edit

#} + {% endif %} + +
+ {% if preview %} + + {% else %} + + {% endif %} +

+ + {{ job.summary | safe if job.summary }} + +

+ {% if job.image_path %} + {{ job.title }} + {% endif %} + {{ job.content | md2html | safe }} + +
+{% endblock main %} diff --git a/afpy/templates/pages/jobs.html b/afpy/templates/pages/jobs.html new file mode 100755 index 0000000..c08fb49 --- /dev/null +++ b/afpy/templates/pages/jobs.html @@ -0,0 +1,38 @@ +{% extends '_parts/base.jinja2' %} + +{% block header %} +

{{ title }}

+{% endblock header %} + +{% block main %} + + {% if submitted %} + + {% endif %} + {% for job in jobs %} +
+

{{ job.title }}

+ + {% if job.image_path %} + {{ job.title }} + {% endif %} + {{ job.summary | safe if job.summary }} +

Lire la suite…

+
+ {% endfor %} + + +{% endblock main %} diff --git a/templates/rss.xml b/afpy/templates/pages/planet_rss.xml old mode 100644 new mode 100755 similarity index 100% rename from templates/rss.xml rename to afpy/templates/pages/planet_rss.xml diff --git a/afpy/templates/pages/post.html b/afpy/templates/pages/post.html new file mode 100755 index 0000000..9795d95 --- /dev/null +++ b/afpy/templates/pages/post.html @@ -0,0 +1,33 @@ +{% extends '_parts/base.jinja2' %} + +{% block header %} +

{{ post.title }}

+{% endblock header %} + +{% block main %} + {% if preview %} + Retour à l'interface d'administration + {#

Edit

#} + {% endif %} + +
+ {% if preview %} + + {% else %} + + {% endif %} +

+ + {{ post.summary | safe if post.summary }} + +

+ {% if post.image_path %} + {{ post.title }} + {% endif %} + {{ post.content | md2html | safe }} +
+{% endblock main %} diff --git a/afpy/templates/pages/posts.html b/afpy/templates/pages/posts.html new file mode 100755 index 0000000..9071bf2 --- /dev/null +++ b/afpy/templates/pages/posts.html @@ -0,0 +1,38 @@ +{% extends '_parts/base.jinja2' %} + +{% block header %} +

{{ title }}

+{% endblock header %} + +{% block main %} + + {% if submitted %} + + {% endif %} + {% for post in posts %} +
+

{{ post.title }}

+ + {% if post.image_path %} + {{ post.title }} + {% endif %} + {{ post.summary | safe if post.summary }} +

Lire la suite…

+
+ {% endfor %} + + +{% endblock main %} diff --git a/afpy/templates/pages/rss.xml b/afpy/templates/pages/rss.xml new file mode 100755 index 0000000..fe4fa26 --- /dev/null +++ b/afpy/templates/pages/rss.xml @@ -0,0 +1,20 @@ + + + {{ title }} + {{ description }} + {{ link }} + fr + {% for entry in entries %} + + <![CDATA[ {{ entry.title | safe }} ]]> + + {% if type == "emplois" %} + {{ url_for("jobs.jobs_render", post_id=entry.id, _external=True) }} + {% else %} + {{ url_for("posts.post_render", post_id=entry.id, _external=True) }} + {% endif %} + {{ entry.dt_published }} + + {% endfor %} + + diff --git a/afpy/templates/pages/rst.html b/afpy/templates/pages/rst.html new file mode 100755 index 0000000..e16df4c --- /dev/null +++ b/afpy/templates/pages/rst.html @@ -0,0 +1,9 @@ +{% extends '_parts/base.jinja2' %} + +{% block header %} +

{{ title }}

+{% endblock header %} + +{% block main %} + {{ html | safe }} +{% endblock main %} diff --git a/templates/a-propos.rst b/afpy/templates/rest/a-propos.rst old mode 100644 new mode 100755 similarity index 100% rename from templates/a-propos.rst rename to afpy/templates/rest/a-propos.rst diff --git a/templates/already_published.rst b/afpy/templates/rest/already_published.rst old mode 100644 new mode 100755 similarity index 100% rename from templates/already_published.rst rename to afpy/templates/rest/already_published.rst diff --git a/templates/already_trashed.rst b/afpy/templates/rest/already_trashed.rst old mode 100644 new mode 100755 similarity index 100% rename from templates/already_trashed.rst rename to afpy/templates/rest/already_trashed.rst diff --git a/templates/charte.rst b/afpy/templates/rest/charte.rst old mode 100644 new mode 100755 similarity index 100% rename from templates/charte.rst rename to afpy/templates/rest/charte.rst diff --git a/templates/contact.rst b/afpy/templates/rest/contact.rst old mode 100644 new mode 100755 similarity index 100% rename from templates/contact.rst rename to afpy/templates/rest/contact.rst diff --git a/templates/legal.rst b/afpy/templates/rest/legal.rst old mode 100644 new mode 100755 similarity index 100% rename from templates/legal.rst rename to afpy/templates/rest/legal.rst diff --git a/templates/rss.rst b/afpy/templates/rest/rss.rst old mode 100644 new mode 100755 similarity index 100% rename from templates/rss.rst rename to afpy/templates/rest/rss.rst diff --git a/afpy/utils.py b/afpy/utils.py new file mode 100644 index 0000000..d2f54f3 --- /dev/null +++ b/afpy/utils.py @@ -0,0 +1,86 @@ +from functools import partial + +import bleach +import markdown2 + + +ALLOWED_TAGS = [ + # Bleach Defaults + "a", + "abbr", + "acronym", + "b", + "u", + "blockquote", + "code", + "em", + "i", + "li", + "ol", + "strong", + "ul", + # Custom Additions + "br", + "caption", + "cite", + "col", + "colgroup", + "dd", + "del", + "details", + "div", + "dl", + "dt", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "img", + "p", + "pre", + "span", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "tt", + "kbd", + "var", +] + +ALLOWED_ATTRIBUTES = { + # Bleach Defaults + "a": ["href", "title"], + "abbr": ["title"], + "acronym": ["title"], + # Custom Additions + "*": ["id"], + "hr": ["class"], + "img": ["src", "width", "height", "alt", "align", "class"], + "span": ["class"], + "div": ["class"], + "th": ["align"], + "td": ["align"], + "code": ["class"], + "p": ["align", "class"], +} + +ALLOWED_STYLES = [] + + +def markdown_to_html(content): + return bleach.sanitizer.Cleaner( + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + styles=ALLOWED_STYLES, + filters=[partial(bleach.linkifier.LinkifyFilter, skip_tags=["pre"], parse_email=False)], + strip=True, + ).clean(markdown2.markdown(content)) diff --git a/data_xml.py b/data_xml.py deleted file mode 100644 index 4ea38cc..0000000 --- a/data_xml.py +++ /dev/null @@ -1,179 +0,0 @@ -import email -from pathlib import Path -import time -from xml.etree import ElementTree - -from werkzeug.utils import secure_filename - -POST_ACTUALITIES = 'actualites' -POST_JOBS = 'emplois' -POSTS = {POST_ACTUALITIES: "Actualités", POST_JOBS: "Offres d’emploi"} - -STATE_WAITING = 'waiting' -STATE_PUBLISHED = 'published' -STATE_TRASHED = 'trashed' -STATES = { - STATE_WAITING: "En attente", - STATE_PUBLISHED: "Publié", - STATE_TRASHED: "À la corbeille" -} - -ACTION_PUBLISH = 'publish' -ACTION_UNPUBLISH = 'unpublish' -ACTION_REPUBLISH = 'republish' -ACTION_TRASH = 'trash' -ACTION_EDIT = 'edit' -ACTION_DELETE_IMAGE = 'delete_image' -ACTIONS = { - ACTION_PUBLISH: "Publier", - ACTION_UNPUBLISH: "Dépublier", - ACTION_REPUBLISH: "Republier", - ACTION_TRASH: "Supprimer", - ACTION_EDIT: 'Editer', - ACTION_DELETE_IMAGE: "Supprimer l'image" -} - -IMAGE = '_image' -TIMESTAMP = '_timestamp' -STATE = '_state' -PATH = '_path' -DIR = '_dir' - -BASE_DIR = 'posts' -BASE_FILE = 'post.xml' -BASE_IMAGE = 'post.jpg' - - -class DataException(Exception): - def __init__(self, *args, http_code=None, **kwargs): - self.http_code = http_code - super().__init__(*args, **kwargs) - - -root = Path(__file__).parent / BASE_DIR -for category in POSTS: - for state in STATES: - (root / category / state).mkdir(parents=True, exist_ok=True) - - -def get_path(category, state, timestamp, *args, create_dir=False): - path = root / category / state / timestamp - if create_dir: - path.mkdir(exist_ok=True) - for arg in args: - path /= arg - return path - - -def count_posts(category, state=STATE_PUBLISHED): - return len(tuple((root / category / state).iterdir())) - - -def get_posts(category, state=STATE_PUBLISHED, start=0, end=None): - path = root / category / state - timestamps = sorted(path.iterdir(), reverse=True) - timestamps = timestamps[start:end] if end else timestamps[start:] - for timestamp in timestamps: - post = get_post(category, timestamp.name, state) - if post: - yield post - - -def get_post(category, timestamp, states=None): - states = ( - states - if isinstance(states, (tuple, list)) - else [states] - if isinstance(states, str) - else STATES - ) - for state in states: - dir = root / category / state / timestamp - path = dir / BASE_FILE - if path.is_file(): - break - else: - return None - tree = ElementTree.parse(path) - post = {item.tag: (item.text or '').strip() for item in tree.iter()} - - # Calculated fields - image = post.get('image') or BASE_IMAGE - if (dir / image).is_file(): - post[IMAGE] = '/'.join((category, state, timestamp, image)) - post[TIMESTAMP] = timestamp - post[STATE] = state - post[DIR] = dir - post[PATH] = path - return post - - -def save_post(category, timestamp, admin, form, files): - if timestamp is None: - status = STATE_WAITING - timestamp = str(int(time.time())) - elif get_path(category, STATE_WAITING, timestamp, BASE_FILE).is_file(): - status = STATE_WAITING - elif get_path(category, STATE_PUBLISHED, timestamp, BASE_FILE).is_file(): - status = STATE_PUBLISHED - elif get_path(category, STATE_TRASHED, timestamp, BASE_FILE).is_file(): - status = STATE_TRASHED - else: - raise DataException(http_code=404) - if status == STATE_TRASHED and not admin: - raise DataException(http_code=401) - - post = get_path(category, status, timestamp, BASE_FILE, create_dir=True) - tree = ElementTree.Element('entry') - - for key, value in form.items(): - if key.startswith('_'): - continue - element = ElementTree.SubElement(tree, key) - element.text = value - - if '_image_path' in form: - image_path = root / form['_image_path'] - if ACTION_DELETE_IMAGE in form and image_path.exists(): - image_path.unlink() - else: - if 'image' in files and files['image'].filename: - post_image = files['image'] - filename = secure_filename(post_image.filename) - post_image.save(str(post.parent / filename)) - element = ElementTree.SubElement(tree, 'image') - element.text = filename - elif '_image_path' in form: - element = ElementTree.SubElement(tree, 'image') - element.text = image_path.name - - element = ElementTree.SubElement(tree, STATE_PUBLISHED) - element.text = email.utils.formatdate( - int(timestamp) if timestamp else time.time() - ) - ElementTree.ElementTree(tree).write(post) - - if ACTION_TRASH in form and status == STATE_PUBLISHED: - (root / category / STATE_PUBLISHED / timestamp).rename( - root / category / STATE_TRASHED / timestamp - ) - if not admin and ACTION_EDIT in form and status == STATE_PUBLISHED: - (root / category / STATE_PUBLISHED / timestamp).rename( - root / category / STATE_WAITING / timestamp - ) - - if admin: - if ACTION_PUBLISH in form and status == STATE_WAITING: - (root / category / STATE_WAITING / timestamp).rename( - root / category / STATE_PUBLISHED / timestamp - ) - elif ACTION_UNPUBLISH in form and status == STATE_PUBLISHED: - (root / category / STATE_PUBLISHED / timestamp).rename( - root / category / STATE_WAITING / timestamp - ) - elif ACTION_REPUBLISH in form and status == STATE_TRASHED: - (root / category / STATE_TRASHED / timestamp).rename( - root / category / STATE_PUBLISHED / timestamp - ) - - return get_post(category, timestamp) diff --git a/posts.tar.bz2 b/posts.tar.bz2 deleted file mode 100644 index 96f2724..0000000 Binary files a/posts.tar.bz2 and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7b5cfff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +# Example configuration for Black. + +# NOTE: you have to use single-quoted strings in TOML for regular expressions. +# It's the equivalent of r-strings in Python. Multiline strings are treated as +# verbose regular expressions by Black. Use [ ] to denote a significant space +# character. + +[tool.black] +line-length = 120 +py36 = true +include = '\.pyi?$' +exclude = ''' +/( + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | \venv + | _build + | buck-out + | build + | dist + | frontend + | www + +)/ +''' \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7c59a97 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +pre-commit +pytest +pytest-cov +pytest-flake8 +pytest-black \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fb316bf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +Flask==1.1.2 +python-dotenv==0.15.0 +docutils==0.16 +peewee==3.14.0 +flask-admin==1.5.7 +wtf-peewee==3.0.2 +Flask-WTF==0.14.3 +email_validator==1.1.2 +flask_login==0.5.0 +Werkzeug==1.0.1 +WTForms==2.3.3 +feedparser==6.0.2 +xmltodict==0.12.0 +flask-pagedown==0.3.0 +markdown2==2.3.10 +bleach==3.2.1 +html2text==2020.1.16 diff --git a/run.py b/run.py new file mode 100644 index 0000000..0654349 --- /dev/null +++ b/run.py @@ -0,0 +1,13 @@ +import os + +from afpy import application + + +if __name__ == "__main__": + # Get the port defined in env if defined, otherwise sets it to 5000 + port = int(os.environ.get("FLASK_PORT", "5000")) + # Default debug is true + debug = bool(os.environ.get("FLASK_DEBUG", False)) + + # Runs the main loop + application.run(host=os.getenv("FLASK_HOST", "0.0.0.0"), port=port, debug=debug) diff --git a/sass/style.sass b/sass/style.sass deleted file mode 100644 index e785a32..0000000 --- a/sass/style.sass +++ /dev/null @@ -1,283 +0,0 @@ -@import url('https://fonts.googleapis.com/css?family=Hind:300,600,700') -$bkg: #25252d -$header: #1d1e23 -$action: #2e5cfd -$action-secondary: #ffcd05 -$text: #eaeaea - -a - color: $action-secondary - font-weight: 700 - text-decoration: none - transition: color 250ms - - &:hover - color: lighten($action-secondary, 10%) - - &.case-sensitive - text-transform: none - -label - display: block - margin: 1em 0 - max-width: 40em - width: 80% - - input, select, textarea - background: $bkg - border: 1px solid - color: $text - display: block - padding: 0.2em - width: 100% - - &:focus - border-color: $action-secondary - -textarea - height: 5em - -.button - background: $action - border: 0 - color: $text - cursor: pointer - font-family: 'Hind', sans-serif - outline: transparent - padding: 1em 2em - text-transform: uppercase - transition: background 250ms - width: auto - - &:hover - background: lighten($action, 5%) - -.nicEdit-panelContain, .nicEdit-pane - color: black - - input[type="submit"] - color: black - -code - background: $header - border-bottom: 1px solid $action-secondary - display: block - padding: 2em - -table - border-collapse: collapse - margin: 1em 0 - - thead, tr:nth-child(even) - background: $header - - td, th - padding: 0.3em 1em - -iframe - background: $text - border: 0 - height: 55em - width: 100% - -body - background: $bkg - color: $text - display: flex - flex-direction: column - font-family: 'Hind', sans-serif - font-size: .9em - font-weight: 300 - margin: 0 - min-height: 100vh - padding: 0 - -header - background: $header - box-sizing: border-box - order: 1 - padding: 0 1em - -.wrapper - width: 100% - max-width: 1200px - margin: 0 auto - box-sizing: border-box - -.menu - display: flex - flex-direction: row - justify-content: flex-start - align-items: center - padding: 0 1em - min-height: 70px - - // Footer navigation - &--footer - background: $header - box-sizing: border-box - margin-top: 2em - order: 4 - - &__toggle - order: 2 - text-align: right - align-self: flex-start - padding-top: 1em - @media screen and (min-width: 840px) - display: none - - &__checkbox - display: none - &:checked + .menu__list - max-height: 84px - overflow: hidden - - // ul element - &__list - flex: 1 1 100% - display: flex - flex-direction: column - list-style: none - margin: 0 - padding: 0 - max-height: 1000px - transition: max-height .3s - - @media screen and (min-width: 840px) - flex-direction: row - justify-content: center - align-items: center - - a - color: $text - display: block - font-weight: 600 - text-decoration: none - - .active a - color: $action-secondary - - &__item - padding: 1em 0 - font-size: .8em - text-transform: uppercase - white-space: nowrap - @media screen and (min-width: 840px) - padding: 1em - - &--brand - flex: 1 1 100% - padding-left: 0 - - .brand - flex: 1 1 100% - display: flex - flex-direction: row - align-items: center - font-size: 1.5em - img - padding-right: 1em - a - font-weight: normal - text-transform: none - padding: 0 - -main - box-sizing: border-box - flex-grow: 1 - margin: 1em auto 0 - max-width: 1200px - order: 3 - padding: 0 1em - width: 100% - -aside - background: $header - margin: 1em auto - padding: 1em 2em - width: 80% - -footer - ul - display: flex - justify-content: center - list-style: none - padding: 0 - -h1 - color: $action-secondary - font-weight: 300 - margin: 2em auto - max-width: 1200px - - &::after - background: $action-secondary - content: '' - display: block - height: 3px - width: 30px - - abbr - display: block - -h2 - font-weight: 400 - -dd - margin-left: 1em - - p:before - content: '→ ' - display: inline - -time - display: block - -article - img - max-width: 100% - -#actualites main, #emplois main, #index-news - box-sizing: border-box - display: flex - flex-wrap: wrap - - article - background: lighten($bkg, 5%) - border: 1px solid $bkg - box-sizing: border-box - flex: 1 50% - padding: 2em - word-wrap: break-word - - a - color: $action-secondary - font-size: .8em - font-weight: 700 - text-decoration: none - text-transform: uppercase - transition: color 250ms - - &:hover - color: lighten($action-secondary, 10%) - - h2 - flex: 1 100% - - article - background: lighten($bkg, 5%) - border: 1px solid $bkg - box-sizing: border-box - flex: 1 50% - padding: 2em - - h2 - font-size: 1.2em - font-weight: 600 - -@media screen and (max-width: 640px) - #actualites main, #emplois main, #index-news - article - flex: 1 100% - width: 100% diff --git a/setup.py b/setup.py deleted file mode 100644 index f5373f3..0000000 --- a/setup.py +++ /dev/null @@ -1,34 +0,0 @@ -from setuptools import find_packages, setup - -tests_requirements = [ - 'pytest', - 'pytest-cov', - 'pytest-flake8', - 'pytest-isort', - 'black', - 'isort', -] - -setup( - name='afpy', - version='0.1.dev0', - description='Site web de l\'Afpy', - url='https://www.afpy.org', - author='Afpy', - packages=find_packages(), - include_package_data=True, - install_requires=[ - 'Flask', - 'Flask-Caching', - 'libsass', - 'docutils', - 'feedparser', - 'python-dateutil', - 'itsdangerous', - ], - scripts=['afpy.py'], - setup_requires=['pytest-runner'], - tests_require=tests_requirements, - extras_require={'test': tests_requirements, - 'sentry': 'sentry-sdk[flask]'} -) diff --git a/static/css/style.sass.css b/static/css/style.sass.css deleted file mode 100644 index 4d3813a..0000000 --- a/static/css/style.sass.css +++ /dev/null @@ -1,251 +0,0 @@ -@charset "UTF-8"; -@import url("https://fonts.googleapis.com/css?family=Hind:300,600,700"); -a { - color: #ffcd05; - font-weight: 700; - text-decoration: none; - transition: color 250ms; } - a:hover { - color: #ffd738; } - a.case-sensitive { - text-transform: none; } - -label { - display: block; - margin: 1em 0; - max-width: 40em; - width: 80%; } - label input, label select, label textarea { - background: #25252d; - border: 1px solid; - color: #eaeaea; - display: block; - padding: 0.2em; - width: 100%; } - label input:focus, label select:focus, label textarea:focus { - border-color: #ffcd05; } - -textarea { - height: 5em; } - -.button { - background: #2e5cfd; - border: 0; - color: #eaeaea; - cursor: pointer; - font-family: 'Hind', sans-serif; - outline: transparent; - padding: 1em 2em; - text-transform: uppercase; - transition: background 250ms; - width: auto; } - .button:hover { - background: #4770fd; } - -.nicEdit-panelContain, .nicEdit-pane { - color: black; } - .nicEdit-panelContain input[type="submit"], .nicEdit-pane input[type="submit"] { - color: black; } - -code { - background: #1d1e23; - border-bottom: 1px solid #ffcd05; - display: block; - padding: 2em; } - -table { - border-collapse: collapse; - margin: 1em 0; } - table thead, table tr:nth-child(even) { - background: #1d1e23; } - table td, table th { - padding: 0.3em 1em; } - -iframe { - background: #eaeaea; - border: 0; - height: 55em; - width: 100%; } - -body { - background: #25252d; - color: #eaeaea; - display: flex; - flex-direction: column; - font-family: 'Hind', sans-serif; - font-size: .9em; - font-weight: 300; - margin: 0; - min-height: 100vh; - padding: 0; } - -header { - background: #1d1e23; - box-sizing: border-box; - order: 1; - padding: 0 1em; } - -.wrapper { - width: 100%; - max-width: 1200px; - margin: 0 auto; - box-sizing: border-box; } - -.menu { - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - padding: 0 1em; - min-height: 70px; } - .menu--footer { - background: #1d1e23; - box-sizing: border-box; - margin-top: 2em; - order: 4; } - .menu__toggle { - order: 2; - text-align: right; - align-self: flex-start; - padding-top: 1em; } - @media screen and (min-width: 840px) { - .menu__toggle { - display: none; } } - .menu__checkbox { - display: none; } - .menu__checkbox:checked + .menu__list { - max-height: 84px; - overflow: hidden; } - .menu__list { - flex: 1 1 100%; - display: flex; - flex-direction: column; - list-style: none; - margin: 0; - padding: 0; - max-height: 1000px; - transition: max-height .3s; } - @media screen and (min-width: 840px) { - .menu__list { - flex-direction: row; - justify-content: center; - align-items: center; } } - .menu__list a { - color: #eaeaea; - display: block; - font-weight: 600; - text-decoration: none; } - .menu__list .active a { - color: #ffcd05; } - .menu__item { - padding: 1em 0; - font-size: .8em; - text-transform: uppercase; - white-space: nowrap; } - @media screen and (min-width: 840px) { - .menu__item { - padding: 1em; } } - .menu__item--brand { - flex: 1 1 100%; - padding-left: 0; } - .menu__item .brand { - flex: 1 1 100%; - display: flex; - flex-direction: row; - align-items: center; - font-size: 1.5em; } - .menu__item .brand img { - padding-right: 1em; } - .menu__item .brand a { - font-weight: normal; - text-transform: none; - padding: 0; } - -main { - box-sizing: border-box; - flex-grow: 1; - margin: 1em auto 0; - max-width: 1200px; - order: 3; - padding: 0 1em; - width: 100%; } - -aside { - background: #1d1e23; - margin: 1em auto; - padding: 1em 2em; - width: 80%; } - -footer ul { - display: flex; - justify-content: center; - list-style: none; - padding: 0; } - -h1 { - color: #ffcd05; - font-weight: 300; - margin: 2em auto; - max-width: 1200px; } - h1::after { - background: #ffcd05; - content: ''; - display: block; - height: 3px; - width: 30px; } - h1 abbr { - display: block; } - -h2 { - font-weight: 400; } - -dd { - margin-left: 1em; } - dd p:before { - content: '→ '; - display: inline; } - -time { - display: block; } - -article img { - max-width: 100%; } - -#actualites main, #emplois main, #index-news { - box-sizing: border-box; - display: flex; - flex-wrap: wrap; } - #actualites main article, #emplois main article, #index-news article { - background: #31313b; - border: 1px solid #25252d; - box-sizing: border-box; - flex: 1 50%; - padding: 2em; - word-wrap: break-word; } - #actualites main article a, #emplois main article a, #index-news article a { - color: #ffcd05; - font-size: .8em; - font-weight: 700; - text-decoration: none; - text-transform: uppercase; - transition: color 250ms; } - #actualites main article a:hover, #emplois main article a:hover, #index-news article a:hover { - color: #ffd738; } - #actualites main article h2, #emplois main article h2, #index-news article h2 { - flex: 1 100%; } - #actualites main article article, #emplois main article article, #index-news article article { - background: #31313b; - border: 1px solid #25252d; - box-sizing: border-box; - flex: 1 50%; - padding: 2em; } - #actualites main article article h2, #emplois main article article h2, #index-news article article h2 { - font-size: 1.2em; - font-weight: 600; } - -@media screen and (max-width: 640px) { - #actualites main article, #emplois main article, #index-news article { - flex: 1 100%; - width: 100%; } } - -/*# sourceMappingURL=../static/css/style.sass.css.map */ \ No newline at end of file diff --git a/static/js/nicEdit.js b/static/js/nicEdit.js deleted file mode 100644 index 5ae37ca..0000000 --- a/static/js/nicEdit.js +++ /dev/null @@ -1,102 +0,0 @@ -/* NicEdit - Micro Inline WYSIWYG - * Copyright 2007-2008 Brian Kirchoff - * - * NicEdit is distributed under the terms of the MIT license - * For more information visit http://nicedit.com/ - * Do not remove this copyright message - */ -var bkExtend=function(){var A=arguments;if(A.length==1){A=[this,A[0]]}for(var B in A[1]){A[0][B]=A[1][B]}return A[0]};function bkClass(){}bkClass.prototype.construct=function(){};bkClass.extend=function(C){var A=function(){if(arguments[0]!==bkClass){return this.construct.apply(this,arguments)}};var B=new this(bkClass);bkExtend(B,C);A.prototype=B;A.extend=this.extend;return A};var bkElement=bkClass.extend({construct:function(B,A){if(typeof (B)=="string"){B=(A||document).createElement(B)}B=$BK(B);return B},appendTo:function(A){A.appendChild(this);return this},appendBefore:function(A){A.parentNode.insertBefore(this,A);return this},addEvent:function(B,A){bkLib.addEvent(this,B,A);return this},setContent:function(A){this.innerHTML=A;return this},pos:function(){var C=curtop=0;var B=obj=this;if(obj.offsetParent){do{C+=obj.offsetLeft;curtop+=obj.offsetTop}while(obj=obj.offsetParent)}var A=(!window.opera)?parseInt(this.getStyle("border-width")||this.style.border)||0:0;return[C+A,curtop+A+this.offsetHeight]},noSelect:function(){bkLib.noSelect(this);return this},parentTag:function(A){var B=this;do{if(B&&B.nodeName&&B.nodeName.toUpperCase()==A){return B}B=B.parentNode}while(B);return false},hasClass:function(A){return this.className.match(new RegExp("(\\s|^)nicEdit-"+A+"(\\s|$)"))},addClass:function(A){if(!this.hasClass(A)){this.className+=" nicEdit-"+A}return this},removeClass:function(A){if(this.hasClass(A)){this.className=this.className.replace(new RegExp("(\\s|^)nicEdit-"+A+"(\\s|$)")," ")}return this},setStyle:function(A){var B=this.style;for(var C in A){switch(C){case"float":B.cssFloat=B.styleFloat=A[C];break;case"opacity":B.opacity=A[C];B.filter="alpha(opacity="+Math.round(A[C]*100)+")";break;case"className":this.className=A[C];break;default:B[C]=A[C]}}return this},getStyle:function(A,C){var B=(!C)?document.defaultView:C;if(this.nodeType==1){return(B&&B.getComputedStyle)?B.getComputedStyle(this,null).getPropertyValue(A):this.currentStyle[bkLib.camelize(A)]}},remove:function(){this.parentNode.removeChild(this);return this},setAttributes:function(A){for(var B in A){this[B]=A[B]}return this}});var bkLib={isMSIE:(navigator.appVersion.indexOf("MSIE")!=-1),addEvent:function(C,B,A){(C.addEventListener)?C.addEventListener(B,A,false):C.attachEvent("on"+B,A)},toArray:function(C){var B=C.length,A=new Array(B);while(B--){A[B]=C[B]}return A},noSelect:function(B){if(B.setAttribute&&B.nodeName.toLowerCase()!="input"&&B.nodeName.toLowerCase()!="textarea"){B.setAttribute("unselectable","on")}for(var A=0;A.nicEdit-main p { margin: 0; } - -{% endblock script %} - -{% block header %} -

- {% if post %} - Modification d'un article - {% else %} - Création d'un article - {% endif %} -

-{% endblock header %} - -{% block main %} -
-
- - - - - {% if name == 'emplois' %} - - - - - {% endif %} - - - {% if admin %} - {% if post._state == 'waiting' %} - - {% elif post._state == 'published' %} - - {% else %} - - {% endif %} - {% endif %} - {% if post._state == 'published' %} - - {% endif %} -
- {% if name == 'actualites' %} -

- L'adresse e-mail n'est pas rendue publique, elle est uniquement - utilisée par les modérateurs pour vous contacter si nécessaire. -

- {% endif %} -
-{% endblock main %} diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 6d66990..0000000 --- a/templates/index.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends '_layout.jinja2' %} - -{% block header %} -

AFPy Association Francophone Python

-{% endblock header %} - -{% block main %} -

AFPy

-

- Créée en décembre 2004, l'AFPy (Association Francophone Python) a pour but de promouvoir le langage Python, que ce soit auprès d'un public averti ou débutant. - Pour ce faire, des évènements sont organisés régulièrement au niveau local et d'autres évènements à un niveau plus général. -

- -

Adhérer

-

- Il est possible de soutenir le développement de l'AFPy en cotisant ou en effectuant un don. -

-
- -
- -

Actualités

-
- {% for timestamp, post in posts.items() %} -
-

{{ post.title }}

- - {% if post._image %} - {{ post.title }} - {% endif %} - {{ post.summary | safe }} -

Lire la suite…

-
- {% endfor %} -
-{% endblock main %} diff --git a/templates/irc.html b/templates/irc.html deleted file mode 100644 index 3943b0f..0000000 --- a/templates/irc.html +++ /dev/null @@ -1,47 +0,0 @@ -{% extends "_layout.jinja2" %} - -{% block header %} -

Discussion

-{% endblock header %} - -{% block main %} - - -

Depuis cette page web vous pouvez

- - - -

Bon à savoir

- -

- IRC (Internet Relay Chat) permet d'utiliser plusieurs canaux de discussion en simultané. Si vous vous trouvez sur #afpy et souhaitez rejoindre #python-fr, rien de plus simple, tapez : - /join #python-fr -

- -

- Si vous souhaitez changer de surnom après connexion : - /nick nouveaunom -

- -

Discuter avec l'AFPy (organisation de la communauté)

- - - -

- Vous pouvez aussi accéder au T'chat via un client IRC : irc://irc.libera.chat/afpy. - Nous stockons les archives IRC du canal #afpy. -

- -

Discuter autour de Python

- - - -

- Vous pouvez aussi accéder au T'chat via un client IRC : irc://irc.libera.chat/python-fr. -

- -{% endblock main %} diff --git a/templates/post.html b/templates/post.html deleted file mode 100644 index 0a0edb3..0000000 --- a/templates/post.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends '_layout.jinja2' %} - -{% block header %} -

{{ post.title }}

-{% endblock header %} - -{% block main %} -
- -

- - {{ post.summary | safe if post.summary }} - -

- {% if post._image %} - {{ post.title }} - {% endif %} - {{ post.content | safe }} -
- {% if name == 'emplois' %} - - {% endif %} -{% endblock main %} diff --git a/templates/posts.html b/templates/posts.html deleted file mode 100644 index 0ba907d..0000000 --- a/templates/posts.html +++ /dev/null @@ -1,34 +0,0 @@ -{% extends '_layout.jinja2' %} - -{% block header %} -

{{ title }}

-{% endblock header %} - -{% block main %} - - {% for timestamp, post in posts.items() %} -
-

{{ post.title }}

- - {% if post._image %} - {{ post.title }} - {% endif %} - {{ post.summary | safe if post.summary }} -

Lire la suite…

-
- {% endfor %} - -{% endblock main %} diff --git a/templates/rst.html b/templates/rst.html deleted file mode 100644 index 8644b6a..0000000 --- a/templates/rst.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends '_layout.jinja2' %} - -{% block header %} -

{{ title }}

-{% endblock header %} - -{% block main %} - {{ html | safe }} -{% endblock main %} diff --git a/tests.py b/tests.py index 05172c8..614483a 100644 --- a/tests.py +++ b/tests.py @@ -1,41 +1,41 @@ import pytest -from afpy import app +from afpy import application def test_home(): - response = app.test_client().get("/") + response = application.test_client().get("/") assert response.status_code == 200 @pytest.mark.parametrize("name", ["", "communaute"]) def test_html(name): - response = app.test_client().get(f"/{name}") + response = application.test_client().get(f"/{name}") assert response.status_code == 200 @pytest.mark.parametrize("name", ["charte", "a-propos"]) def test_rest(name): - response = app.test_client().get(f"/docs/{name}") + response = application.test_client().get(f"/docs/{name}") assert response.status_code == 200 def test_planet(): - response = app.test_client().get("/planet/") + response = application.test_client().get("/planet/") assert response.status_code == 200 def test_404(): - response = app.test_client().get("/unknown") + response = application.test_client().get("/unknown") assert response.status_code == 404 - response = app.test_client().get("/docs/unknown") + response = application.test_client().get("/docs/unknown") assert response.status_code == 404 - response = app.test_client().get("/feed/unknown") + response = application.test_client().get("/feed/unknown") assert response.status_code == 404 def test_read_posts(): - response = app.test_client().get("/posts/actualites") + response = application.test_client().get("/actualites/page/1") assert response.status_code == 200 - response = app.test_client().get("/posts/emplois") + response = application.test_client().get("/emplois/page/1") assert response.status_code == 200 diff --git a/xml2sql.py b/xml2sql.py new file mode 100644 index 0000000..05ac59d --- /dev/null +++ b/xml2sql.py @@ -0,0 +1,139 @@ +# coding: utf-8 +import os +import shutil +from pathlib import Path +from xml.etree import ElementTree + +from dateutil.parser import parse +from html2text import html2text + +from afpy.models.AdminUser import AdminUser +from afpy.models.JobPost import JobPost +from afpy.models.NewsEntry import NewsEntry +from afpy.models.Slug import Slug + +PAGINATION = 12 + +CATEGORY_ACTUALITIES = "actualites" +CATEGORY_JOBS = "emplois" + +CATEGORIES = {CATEGORY_ACTUALITIES: "Actualités", CATEGORY_JOBS: "Offres d’emploi"} + +STATE_WAITING = "waiting" +STATE_PUBLISHED = "published" +STATE_TRASHED = "trashed" +STATES = {STATE_WAITING: "En attente", STATE_PUBLISHED: "Publié", STATE_TRASHED: "Supprimé"} + +FIELD_IMAGE = "_image" +FIELD_TIMESTAMP = "_timestamp" +FIELD_STATE = "_state" +FIELD_PATH = "_path" +FIELD_DIR = "_dir" + +BASE_DIR = "posts" +BASE_FILE = "post.xml" +BASE_IMAGE = "post.jpg" + +ROOT_DIR = Path(__file__).parent +POSTS_DIR = ROOT_DIR / BASE_DIR +IMAGE_DIR = ROOT_DIR / "images" +IMAGE_DIR.mkdir(exist_ok=True) + + +def get_posts(category, state=STATE_PUBLISHED, page=None, end=None): + start = 0 + if page and not end: + end = page * PAGINATION + start = end - PAGINATION + path = POSTS_DIR / category / state + timestamps = sorted(path.iterdir(), reverse=True) + timestamps = timestamps[start:end] if end else timestamps[start:] + for timestamp in timestamps: + post = get_post(category, timestamp.name, state) + if post: + yield post + + +def get_post(category, timestamp, states=None): + states = tuple( + states if isinstance(states, (tuple, list)) else [states] if isinstance(states, str) else STATES.keys() + ) + for state in states: + dir = POSTS_DIR / category / state / timestamp + path = dir / BASE_FILE + if path.is_file(): + break + else: + return None + tree = ElementTree.parse(path) + post = {item.tag: (item.text or "").strip() for item in tree.iter()} + + # Calculated fields + image = post.get("image") or post.get("old_image") or BASE_IMAGE + if (dir / image).is_file(): + post[FIELD_IMAGE] = "/".join((category, state, timestamp, image)) + post[FIELD_TIMESTAMP] = timestamp + post[FIELD_STATE] = state + post[FIELD_DIR] = dir + post[FIELD_PATH] = path + return post + + +if __name__ == "__main__": + admin_1 = AdminUser.get_by_id(1) + for category in CATEGORIES: + for state in STATES: + for post in get_posts(category, state): + timestamp = post.get(FIELD_TIMESTAMP) + if post.get(FIELD_IMAGE): + image = POSTS_DIR / post.get(FIELD_IMAGE) + name, ext = os.path.splitext(post.get(FIELD_IMAGE)) + post["image"] = f"{category}.{timestamp}{ext}" + shutil.copy(str(image), str(IMAGE_DIR / post["image"])) + if category == "actualites": + new_post = NewsEntry.create( + title=post.get("title", "(untitled)"), + summary=post.get("summary"), + content=html2text(post.get("content", "")), + author="Admin", + author_email=post.get("email"), + image_path=post.get("image"), + dt_published=parse(post.get("published")).replace(tzinfo=None) + if state == "published" + else None, + dt_submitted=parse(post.get("published")).replace(tzinfo=None), + dt_updated=parse(post.get("published")).replace(tzinfo=None), + state=state, + approved_by=admin_1 if state == "published" or state == "rejected" else None, + ) + Slug.create(url=f"/posts/actualites/{post.get(FIELD_TIMESTAMP)}", newsentry=new_post) + post_id = post.get("id") + if post_id: + Slug.create(url=post_id.split("afpy.org")[-1], newsentry=new_post) + else: + email = post.get("email", "") + phone = post.get("phone", "") + if not email and not phone: + phone = "(no phone)" + new_job = JobPost.create( + title=post.get("title", "(untitled)"), + summary=post.get("summary"), + content=html2text(post.get("content", "")), + company=post.get("company", ""), + email=email, + phone=phone, + location=post.get("address", ""), + contact_info=post.get("contact", ""), + dt_published=parse(post.get("published")).replace(tzinfo=None) + if state == "published" + else None, + dt_submitted=parse(post.get("published")).replace(tzinfo=None), + dt_updated=parse(post.get("published")).replace(tzinfo=None), + state=state, + approved_by=admin_1 if state == "published" or state == "rejected" else None, + image_path=post.get("image"), + ) + Slug.create(url=f"/posts/emplois/{post.get(FIELD_TIMESTAMP)}", jobpost=new_job) + post_id = post.get("id") + if post_id: + Slug.create(url=post_id.split("afpy.org")[-1], jobpost=new_job)