Backend Architecture redo (#58)

This commit is contained in:
Jules Lasne 2021-07-02 09:21:36 +02:00 committed by GitHub
parent 7fb2b17721
commit 9df32984cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
90 changed files with 2423 additions and 1708 deletions

5
.env.template Normal file
View File

@ -0,0 +1,5 @@
FLASK_PORT=5000
FLASK_DEBUG=false
FLASK_HOST=localhost
FLASK_SECRET_KEY=ThisIsADevelopmentKey
DB_NAME=afpy.db

View File

@ -1,2 +1,4 @@
[flake8]
max-line-length = 88
max-line-length = 120
exclude = venv/*
ignore = E402, W291, W503

View File

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

31
.github/workflows/test.yml vendored Normal file
View File

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

4
.gitignore vendored
View File

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

View File

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

15
.pre-commit-config.yaml Normal file
View File

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

View File

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

View File

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

340
afpy.py
View File

@ -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/<name>")
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/<name>")
@app.route("/post/edit/<name>/token/<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/<name>/<timestamp>")
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/<name>", methods=["post"])
@app.route("/post/edit/<name>/token/<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/<name>/<timestamp>", 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/<name>")
@app.route("/posts/<name>/page/<int: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/<name>")
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/<name>/<timestamp>")
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/<path:path>")
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/<name>/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()])

118
afpy/__init__.py Normal file
View File

@ -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("/<path:slug>")
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)

25
afpy/config.py Normal file
View File

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

18
afpy/data/data.json Normal file
View File

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

23
afpy/forms/JobPost.py Normal file
View File

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

14
afpy/forms/NewsEntry.py Normal file
View File

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

0
afpy/forms/__init__.py Normal file
View File

56
afpy/forms/auth.py Normal file
View File

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

58
afpy/models/AdminUser.py Normal file
View File

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

109
afpy/models/JobPost.py Normal file
View File

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

95
afpy/models/NewsEntry.py Normal file
View File

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

27
afpy/models/Slug.py Normal file
View File

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

8
afpy/models/__init__.py Normal file
View File

@ -0,0 +1,8 @@
from peewee import Model
from afpy import database
class BaseModel(Model):
class Meta:
database = database

0
afpy/routes/__init__.py Normal file
View File

158
afpy/routes/admin.py Normal file
View File

@ -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("/<type>", 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/<type>/<id>", 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/<id>/<type>/<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

77
afpy/routes/home.py Normal file
View File

@ -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/<name>")
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/<int:post_id>")
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/<int:current_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/<path:path>")
def get_image(path):
return send_from_directory(config.IMAGES_PATH, path)

80
afpy/routes/jobs.py Normal file
View File

@ -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/<int:post_id>")
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/<int:current_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")

69
afpy/routes/posts.py Normal file
View File

@ -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/<int:post_id>")
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/<int:current_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")

64
afpy/routes/rss.py Normal file
View File

@ -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/<type>/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),
)

254
afpy/static/css/style.sass.css Executable file
View File

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

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

0
static/images/logo.svg → afpy/static/images/logo.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -0,0 +1,25 @@
<!doctype html>
<html lang="fr">
{% include "_parts/html_head.jinja2" %}
<body id="{{ body_id }}">
<header>
<div class="wrapper">
{% block header %}
{% endblock header %}
</div>
</header>
{% include "_parts/nav_menu.jinja2" %}
<main>
{% include "_parts/flashes.jinja2" %}
{% block main %}
{% endblock main %}
</main>
{% include "_parts/html_footer.jinja2" %}
</body>
</html>

View File

@ -0,0 +1,12 @@
{% block flashes %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<!-- <strong>Title</strong> --> {{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% endblock %}

View File

@ -0,0 +1,17 @@
{% block html_footer %}
<footer class="wrapper menu menu--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'),
] -%}
<ul class="menu__list">
{% for href, caption in navigation_bar %}
<li class="menu__item"><a href="{{ href|e }}">{{ caption|e }}</a></li>
{% endfor %}
</ul>
</footer>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% block html_head %}
<head>
<meta charset="utf-8">
<link rel="icon" href="{{ url_for('static', filename='images/favicon.ico') }}" />
<title>AFPY - Le site web de l'Association Francophone Python</title>
<link type="text/css" rel="stylesheet" href="{{ url_for('static', filename='css/style.sass.css') }}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
{{ pagedown.include_pagedown() }}
</head>
{% endblock %}

View File

@ -0,0 +1,28 @@
{% block nav_menu %}
<nav class="wrapper menu menu--main">
{% set navigation_bar = [
(url_for('home.home_page'), 'index', 'Accueil'),
(url_for('home.render_rest', name='a-propos'), 'a-propos', 'Qui sommes-nous ?'),
(url_for('posts.posts_page', current_page='1'), 'actualites', 'Actualités'),
(url_for('jobs.jobs_page', current_page='1'), 'emplois', 'Offres d\'emplois'),
(url_for('home.community_page'), 'communaute', 'Communauté'),
('https://discuss.afpy.org', 'discussion', 'Discussion'),
(url_for('home.discussion_page'), 'irc', 'IRC'),
(url_for('home.adhere_page'), 'adhesions', 'Adhésions')
] -%}
<label for="toggle" class="menu__toggle">Menu</label>
<input class="menu__checkbox" type="checkbox" name="toggle" id="toggle" checked>
<ul class="menu__list">
<li class="menu__item menu__item--brand">
<a class="brand" href="{{ url_for('home.home_page') }}">
<img src="{{ url_for('static', filename='images/logo.svg') }}" title="Logo afpy" />
<span>AFPy</span>
</a>
</li>
{% for href, id, caption in navigation_bar %}
<li class="menu__item{% if id == body_id %} active{% endif
%}"><a href="{{ href|e }}">{{ caption|e }}</a></li>
{% endfor %}
</ul>
</nav>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends 'admin/base.html' %}
{% block access_control %}
{% if current_user.is_authenticated %}
<div class="btn-group pull-right">
<a class="btn dropdown-toggle" data-toggle="dropdown" href="#">
<i class="icon-user"></i> {{ current_user.login }} <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li><a href="{{ url_for('admin.logout_view') }}">Log out</a></li>
</ul>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,46 @@
{% extends 'admin/master.html' %}
{% block body %}
{{ super() }}
{% include "_parts/flashes.jinja2" %}
<div class="row-fluid">
<div>
<form class="form-horizontal" action='{{ url_for("change_password.change_password_view") }}' method="POST">
<fieldset>
<div id="legend">
<legend class="">Change your password</legend>
</div>
<div class="control-group">
<label class="control-label" for="password">Old password</label>
<div class="controls">
{{ form.old_password }}
<p class="help-block">Your old password</p>
</div>
</div>
<div class="control-group">
<label class="control-label" for="password">New password</label>
<div class="controls">
{{ form.new_password }}
<p class="help-block">Your new password</p>
</div>
</div>
<div class="control-group">
<label class="control-label" for="password">New password confirmation</label>
<div class="controls">
{{ form.new_password_confirmation }}
<p class="help-block">Confirm your new password</p>
</div>
</div>
<div class="control-group">
<div class="controls">
<button type="submit" class="btn btn-success">Confirm</button>
</div>
</div>
</fieldset>
</form>
</div>
</div>
{% endblock body %}

View File

@ -0,0 +1,50 @@
{% extends 'admin/master.html' %}
{% block body %}
{{ super() }}
{% include "_parts/flashes.jinja2" %}
<div class="row-fluid">
<div>
<form class="form-horizontal" action='{{ url_for("register_admin.register_view") }}' method="POST">
<fieldset>
<div id="legend">
<legend class="">Register</legend>
</div>
<div class="control-group">
<!-- Username -->
<label class="control-label" for="username">Username</label>
<div class="controls">
{{ form.username }}
<p class="help-block">Username can contain any letters or numbers, without spaces</p>
</div>
</div>
<div class="control-group">
<!-- E-mail -->
<label class="control-label" for="email">E-mail</label>
<div class="controls">
{{ form.email }}
<p class="help-block">Please provide your E-mail</p>
</div>
</div>
<div class="control-group">
<!-- Password-->
<label class="control-label" for="password">Password</label>
<div class="controls">
{{ form.password }}
<p class="help-block">Password should be at least 4 characters</p>
</div>
</div>
<div class="control-group">
<!-- Button -->
<div class="controls">
<button type="submit" class="btn btn-success">Register</button>
</div>
</div>
</fieldset>
</form>
</div>
</div>
{% endblock body %}

View File

@ -0,0 +1,36 @@
{% extends 'admin/master.html' %}
{% block body %}
{{ super() }}
<div class="row-fluid">
{% include "_parts/flashes.jinja2" %}
<div>
{% if current_user.is_authenticated %}
<h1>AFPy Backend Admin</h1>
<p class="lead">
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.<br>
Ne touchez à rien si vous ne savez pas ce que vous faites.
</p>
{% else %}
<form method="POST" action="">
{{ form.hidden_tag() if form.hidden_tag }}
{% for f in form if f.type != 'CSRFTokenField' %}
<div>
{{ f.label }}
{{ f }}
{% if f.errors %}
<ul>
{% for e in f.errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
<button class="btn btn-success" type="submit">Submit</button>
</form>
{{ link | safe }}
{% endif %}
</div>
</div>
{% endblock body %}

View File

@ -0,0 +1,35 @@
{% extends 'admin/master.html' %}
{% block body %}
{{ super() }}
{% include "_parts/flashes.jinja2" %}
<div class="row-fluid">
<div class="container">
<!-- Example row of columns -->
<div class="row">
{% if not items %}
<p>Nothing to moderate.</p>
{% else %}
{% for item in items %}
<div class="col-md-3">
<h2>{{ item.title }}</h2>
{% if item.image_path %}
<p class="text-warning">An image exists but has not been displayed</p>
{% endif %}
{% if item.summary %}
<p>{{ item.summary }}</p>
{% endif %}
{% if type == "jobs" %}
<p><a class="btn btn-secondary" href="{{ url_for("moderation.preview_item", type="jobs", id=item.id) }}" role="button">Preview</a></p>
<p><a class="btn btn-warning" href="http://127.0.0.1:5000/admin/jobpost/edit/?id={{ item.id }}" role="button">Edit</a></p>
<p><a class="btn btn-success" href="{{ url_for("moderation.moderate_action", id=item.id, type="jobs", action="approve") }}" role="button">Approve</a> <a class="btn btn-danger" href="{{ url_for("moderation.moderate_action", id=item.id, type="jobs", action="reject") }}" role="button">Reject</a></p>
{% else %}
<p><a class="btn btn-secondary" href="{{ url_for("moderation.preview_item", type="news", id=item.id) }}" role="button">Preview</a></p>
<p><a class="btn btn-warning" href="http://127.0.0.1:5000/admin/newsentry/edit/?id={{ item.id }}" role="button">Edit</a></p>
<p><a class="btn btn-success" href="{{ url_for("moderation.moderate_action", id=item.id, type="news", action="approve") }}" role="button">Approve</a> <a class="btn btn-danger" href="{{ url_for("moderation.moderate_action", id=item.id, type="news", action="reject") }}" role="button">Reject</a></p>
{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
</div>
{% endblock body %}

View File

@ -0,0 +1,18 @@
{% extends 'admin/master.html' %}
{% block body %}
{{ super() }}
<div class="row-fluid">
{% include "_parts/flashes.jinja2" %}
<div>
{% if current_user.is_authenticated %}
<div class="col-md-4 text-center mx-3">
<a class="btn btn-primary" href="{{ url_for("moderation.moderate_view", type="jobs") }}"><i class="icon-arrow-left icon-white"></i>Moderate Jobs</a>
</div>
<div class="col-xs-12 col-md-4 text-center mx-3">
<a class="btn btn-primary" href="{{ url_for("moderation.moderate_view", type="news") }}"><i class="icon-arrow-left icon-white"></i>Moderate News</a>
</div>
{% endif %}
</div>
</div>
{% endblock body %}

View File

@ -0,0 +1,31 @@
{% extends '_layout.jinja2' %}
{% block header %}
<h1>Confirmation de l'enregistrement de l'article</h1>
{% endblock header %}
{% block main %}
<h2>Merci de votre participation</h2>
<p>
Votre article a bien été enregistré. Il sera mis en ligne après acceptation
de l'un des modérateurs.
</p>
<p>
En attendant, vous pouvez toujours la modifier en utilisant le lien :
<a class="case-sensitive" href="{{ edit_post_url }}">{{ edit_post_url }}</a>.
Attention à conserver celui-ci secret !
</p>
<h2>Demande d'informations complémentaires</h2>
<p>
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 <a class="reference external" href="/discussion">Discussion</a>.
</p>
{% endblock main %}

9
afpy/templates/pages/404.html Executable file
View File

@ -0,0 +1,9 @@
{% extends "_parts/base.jinja2" %}
{% block header %}
<h1>404</h1>
{% endblock header %}
{% block main %}
<p>Ce n'est pas la page que vous cherchez.</p>
{% endblock main %}

View File

@ -0,0 +1,11 @@
{% extends '_parts/base.jinja2' %}
{% block header %}
<h1>Adhésions</h1>
{% endblock header %}
{% block main %}
<h2>Adhérez à l'AFPy</h2>
<iframe id="haWidget" src="https://www.helloasso.com/associations/afpy/adhesions/adhesion-2021-a-l-afpy/widget"></iframe>
<p>Si le widget ne fonctionne pas, essayez cette page : https://www.helloasso.com/associations/afpy/adhesions/adhesion-2021-a-l-afpy</p>
{% endblock main %}

View File

@ -0,0 +1,48 @@
{% extends "_parts/base.jinja2" %}
{% block header %}
<h1>Communauté</h1>
{% endblock header %}
{% block main %}
<h2>Forum de discussion</h2>
<p>
Afin d'échanger avec la communauté, un forum de discussion est disponible et
traite de tous les sujets autour de Python.
</p>
<p>
<a href="https://discuss.afpy.org/">Forum</a>
</p>
<h2>Rencontres</h2>
<p>
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.
</p>
<ul>
{% for city, url in meetups.items() %}
<li><a href="{{ url }}">{{ city | capitalize }}</a></li>
{% endfor %}
</ul>
<h2>PyConFr</h2>
<p>
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.
</p>
<p>
<a href="https://www.pycon.fr/">PyConFr</a>
</p>
<h2>April</h2>
<p>
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.
</p>
<p>
<a href="http://april.org/campagne/">April</a>
</p>
{% endblock main %}

View File

@ -0,0 +1,44 @@
{% extends "_parts/base.jinja2" %}
{% block header %}
<h1>Discussion</h1>
{% endblock header %}
{% block main %}
<p>Depuis cette page web vous pouvez</p>
<ul>
<li>venir discuter avec l'association sur le canal <a href="#tchatafpy">#afpy</a></li>
<li>parler Python en français sur le canal <a href="#tchatpython">#python-fr</a></li>
<li>accéder aux <a href="https://lists.afpy.org/mailman/listinfo/">Mailing Lists</a></li>
</ul>
<h2>Bon à savoir</h2>
<p>
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 :
<code>/join #python-fr</code>
</p>
<p>
Si vous souhaitez changer de surnom après connexion :
<code>/nick nouveaunom</code>
</p>
<h2 id="tchatafpy">Discuter avec l'AFPy (organisation de la communauté)</h2>
<iframe src="https://web.libera.chat/#afpy"></iframe>
<p>
Vous pouvez aussi accéder au T'chat via un client IRC : <a href="irc://irc.freenode.net/afpy">irc://irc.freenode.net/afpy</a>.
Nous stockons <a href="http://logs.afpy.org/">les archives IRC du canal #afpy</a>.
</p>
<h2 id="tchatpython">Discuter autour de Python</h2>
<iframe src="https://web.libera.chat/#python-fr"></iframe>
<p>
Vous pouvez aussi accéder au T'chat via un client IRC : <a href="irc://irc.libera.chat/python-fr">irc://irc.libera.chat/python-fr</a>.
</p>
{% endblock main %}

View File

@ -0,0 +1,61 @@
{% extends '_parts/base.jinja2' %}
{% block header %}
<h1>
{% if post %}
Modification d'un job
{% else %}
Création d'un job
{% endif %}
</h1>
{% endblock header %}
{% block main %}
<article>
<form method="POST" action="{{ url_for("jobs.new_job") }}" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{% for field, errors in form.errors.items() %}
<div class="alert alert-error">
{{ form[field].label }}: {{ ', '.join(errors) }}
</div>
{% endfor %}
<label>{{ form.title.label }}
{{ form.title(size=40) }}
</label>
<label>{{ form.summary.label }}
{{ form.summary(size=100) }}
</label>
<label>{{ form.content.label }}
<div class="pagedown-row">
<div class="pagedown-column">
{{ form.content(only_input=True, rows=30) }}
</div>
<div class="pagedown-column" style="margin-left: 40px;">
Prévisualisation:
{{ form.content(only_preview=True) }}
</div>
</div> </label>
<label>{{ form.company.label }}
{{ form.company(size=40) }}
</label>
<label>{{ form.location.label }}
{{ form.location(size=40) }}
</label>
<label>{{ form.contact_info.label }}
{{ form.contact_info(size=40) }}
</label>
<label>{{ form.email.label }}
{{ form.email(size=40) }}
</label>
<label>{{ form.phone.label }}
{{ form.phone(size=40) }}
</label>
<label>{{ form.image.label }}
{{ form.image }}
</label>
<input type="submit" name="submit" value="Enregistrer" />
</form>
</article>
{% endblock main %}

View File

@ -0,0 +1,57 @@
{% extends '_parts/base.jinja2' %}
{% block header %}
<h1>
{% if post %}
Modification d'un article
{% else %}
Création d'un article
{% endif %}
</h1>
{% endblock header %}
{% block main %}
<article>
<form method="POST" action="{{ url_for("posts.new_post") }}" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{% for field, errors in form.errors.items() %}
<div class="alert alert-error">
{{ form[field].label }}: {{ ', '.join(errors) }}
</div>
{% endfor %}
<label>{{ form.title.label }}
{{ form.title(size=40) }}
</label>
<label>{{ form.summary.label }}
{{ form.summary(size=100) }}
</label>
<label>{{ form.image.label }}
{{ form.image }}
</label>
<label>{{ form.author.label }}
{{ form.author(size=20) }}
</label>
<label>{{ form.author_email.label }}
{{ form.author_email(size=40) }}
</label>
<label>{{ form.content.label }}
<div class="pagedown-row">
<div class="pagedown-column">
{{ form.content(only_input=True, rows=30) }}
</div>
<div class="pagedown-column" style="margin-left: 40px;">
Prévisualisation:
{{ form.content(only_preview=True) }}
</div>
</div>
</label>
<input type="submit" name="submit" value="Enregistrer" />
</form>
<p>
L'adresse e-mail n'est pas rendue publique, elle est uniquement
utilisée par les modérateurs pour vous contacter si nécessaire.
</p>
</article>
{% endblock main %}

38
afpy/templates/pages/index.html Executable file
View File

@ -0,0 +1,38 @@
{% extends '_parts/base.jinja2' %}
{% block header %}
<h1><abbr>AFPy</abbr> Association Francophone Python</h1>
{% endblock header %}
{% block main %}
<h2>AFPy</h2>
<p>
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 <a href="{{ url_for('home.community_page') }}">évènements</a> sont organisés régulièrement au niveau local et d'autres évènements à un niveau plus général.
</p>
<h2>Adhérer</h2>
<p>
Il est possible de soutenir le développement de l'AFPy en cotisant ou en effectuant un don.
</p>
<form action="{{ url_for('home.adhere_page') }}">
<input type="submit" value="S'inscrire" class="button" />
</form>
<h2>Actualités</h2>
<section id="index-news">
{% for post in posts %}
<article>
<h3>{{ post.title }}</h3>
<time pubdate datetime="{{ post.dt_published }}">
{{ post.dt_published.strftime('%d/%m/%Y') }}
</time>
{% if post.image_path %}
<img src="{{ url_for('home.get_image', path=post.image_path) }}" alt="{{ post.title }}" />
{% endif %}
{{ post.summary | safe }}
<p><a href="{{ post|slug_url }}">Lire la suite…</a></p>
</article>
{% endfor %}
</section>
{% endblock main %}

51
afpy/templates/pages/job.html Executable file
View File

@ -0,0 +1,51 @@
{% extends '_parts/base.jinja2' %}
{% block header %}
<h1>{{ job.title }}</h1>
{% endblock header %}
{% block main %}
{% if preview %}
<a href="{{ url_for("moderation.moderate_view", type="jobs") }}">Retour à l'interface d'administration</a>
{# <p><a class="btn btn-warning" href="http://127.0.0.1:5000/admin/jobpost/edit/?id={{ job.id }}" role="button">Edit</a></p>#}
{% endif %}
<article>
{% if preview %}
<time pubdate datetime="">
Pas publié
</time>
{% else %}
<time pubdate datetime="{{ job.dt_published }}">
Posté le {{ job.dt_published.strftime('%d/%m/%Y') }} à {{ job.dt_published.strftime('%H:%M:%S') }}
</time>
{% endif %}
<p>
<em>
{{ job.summary | safe if job.summary }}
</em>
</p>
{% if job.image_path %}
<img src="{{ url_for('home.get_image', path=job.image_path) }}" alt="{{ job.title }}" />
{% endif %}
{{ job.content | md2html | safe }}
<aside>
<h2>{{ job.company or "(Société inconnue)" }}</h2>
<dl>
<dt>Adresse</dt>
<dd>{{ job.location }}</dd>
<dt>Personne à contacter</dt>
<dd>{{ job.contact_info }}</dd>
{% if job.phone %}
<dt>Téléphone</dt>
<dd><a href="tel:{{ job.phone }}">{{ job.phone }}</a></dd>
{% endif %}
{% if job.email %}
<dt>Adresse e-mail</dt>
<dd><a href="mailto:{{ job.email }}">{{ job.email }}</a></dd>
{% endif %}
</dl>
</aside>
</article>
{% endblock main %}

38
afpy/templates/pages/jobs.html Executable file
View File

@ -0,0 +1,38 @@
{% extends '_parts/base.jinja2' %}
{% block header %}
<h1>{{ title }}</h1>
{% endblock header %}
{% block main %}
<aside>
Vous pouvez <a href="{{ url_for('jobs.new_job') }}">créer une nouvelle offre</a> qui
sera mis en ligne après acceptation de l'un des modérateurs.
</aside>
{% if submitted %}
<aside>Merci ! Votre offre d'emploi apparaîtra après validation par un des administrateurs.</aside>
{% endif %}
{% for job in jobs %}
<article>
<h2>{{ job.title }}</h2>
<time pubdate datetime="{{ job.dt_published }}">
{{ job.dt_published.strftime('%d/%m/%Y') }}
</time>
{% if job.image_path %}
<img src="{{ url_for('home.get_image', path=job.image_path) }}" alt="{{ job.title }}" />
{% endif %}
{{ job.summary | safe if job.summary }}
<p><a href="{{ job|slug_url }}">Lire la suite…</a></p>
</article>
{% endfor %}
<aside>
{% if current_page != 1 %}
<a href="{{ url_for('jobs.jobs_page', current_page=(current_page - 1)) }}">Précedente</a>
{% endif %}
Page {{ current_page }}/{{ total_pages }}
{% if current_page != total_pages %}
<a href="{{ url_for('jobs.jobs_page', current_page=(current_page + 1)) }}">Suivante</a>
{% endif %}
</aside>
{% endblock main %}

View File

33
afpy/templates/pages/post.html Executable file
View File

@ -0,0 +1,33 @@
{% extends '_parts/base.jinja2' %}
{% block header %}
<h1>{{ post.title }}</h1>
{% endblock header %}
{% block main %}
{% if preview %}
<a href="{{ url_for("moderation.moderate_view", type="news") }}">Retour à l'interface d'administration</a>
{# <p><a class="btn btn-warning" href="http://127.0.0.1:5000/admin/jobpost/edit/?id={{ post.id }}" role="button">Edit</a></p>#}
{% endif %}
<article>
{% if preview %}
<time pubdate datetime="">
Pas publié
</time>
{% else %}
<time pubdate datetime="{{ post.dt_published }}">
Posté le {{ post.dt_published.strftime('%d/%m/%Y') }} à {{ post.dt_published.strftime('%H:%M:%S') }}
</time>
{% endif %}
<p>
<em>
{{ post.summary | safe if post.summary }}
</em>
</p>
{% if post.image_path %}
<img src="{{ url_for('home.get_image', path=post.image_path) }}" alt="{{ post.title }}" />
{% endif %}
{{ post.content | md2html | safe }}
</article>
{% endblock main %}

38
afpy/templates/pages/posts.html Executable file
View File

@ -0,0 +1,38 @@
{% extends '_parts/base.jinja2' %}
{% block header %}
<h1>{{ title }}</h1>
{% endblock header %}
{% block main %}
<aside>
Vous pouvez <a href="{{ url_for('posts.new_post') }}">créer un article</a> qui
sera mis en ligne après acceptation de l'un des modérateurs.
</aside>
{% if submitted %}
<aside>Merci ! Votre offre d'emploi apparaîtra après validation par un des administrateurs.</aside>
{% endif %}
{% for post in posts %}
<article>
<h2>{{ post.title }}</h2>
<time pubdate datetime="{{ post.dt_published }}">
{{ post.dt_published.strftime('%d/%m/%Y') }}
</time>
{% if post.image_path %}
<img src="{{ url_for('home.get_image', path=post.image_path) }}" alt="{{ post.title }}" />
{% endif %}
{{ post.summary | safe if post.summary }}
<p><a href="{{ post|slug_url }}">Lire la suite…</a></p>
</article>
{% endfor %}
<aside>
{% if current_page != 1 %}
<a href="{{ url_for('posts.posts_page', current_page=(current_page - 1)) }}">Précedente</a>
{% endif %}
Page {{ current_page }}/{{ total_pages }}
{% if current_page != total_pages %}
<a href="{{ url_for('posts.posts_page', current_page=(current_page + 1)) }}">Suivante</a>
{% endif %}
</aside>
{% endblock main %}

20
afpy/templates/pages/rss.xml Executable file
View File

@ -0,0 +1,20 @@
<rss xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" version="2.0">
<channel>
<title>{{ title }}</title>
<description>{{ description }}</description>
<link>{{ link }}</link>
<language>fr</language>
{% for entry in entries %}
<item>
<title><![CDATA[ {{ entry.title | safe }} ]]></title>
<description><![CDATA[ {{ (entry.description or entry.summary) | safe }} ]]></description>
{% if type == "emplois" %}
<link>{{ url_for("jobs.jobs_render", post_id=entry.id, _external=True) }}</link>
{% else %}
<link>{{ url_for("posts.post_render", post_id=entry.id, _external=True) }}</link>
{% endif %}
<pubDate>{{ entry.dt_published }}</pubDate>
</item>
{% endfor %}
</channel>
</rss>

9
afpy/templates/pages/rst.html Executable file
View File

@ -0,0 +1,9 @@
{% extends '_parts/base.jinja2' %}
{% block header %}
<h1>{{ title }}</h1>
{% endblock header %}
{% block main %}
{{ html | safe }}
{% endblock main %}

View File

View File

View File

0
templates/legal.rst → afpy/templates/rest/legal.rst Normal file → Executable file
View File

0
templates/rss.rst → afpy/templates/rest/rss.rst Normal file → Executable file
View File

86
afpy/utils.py Normal file
View File

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

View File

@ -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 demploi"}
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)

Binary file not shown.

29
pyproject.toml Normal file
View File

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

5
requirements-dev.txt Normal file
View File

@ -0,0 +1,5 @@
pre-commit
pytest
pytest-cov
pytest-flake8
pytest-black

17
requirements.txt Normal file
View File

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

13
run.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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<B.childNodes.length;A++){bkLib.noSelect(B.childNodes[A])}},camelize:function(A){return A.replace(/\-(.)/g,function(B,C){return C.toUpperCase()})},inArray:function(A,B){return(bkLib.search(A,B)!=null)},search:function(A,C){for(var B=0;B<A.length;B++){if(A[B]==C){return B}}return null},cancelEvent:function(A){A=A||window.event;if(A.preventDefault&&A.stopPropagation){A.preventDefault();A.stopPropagation()}return false},domLoad:[],domLoaded:function(){if(arguments.callee.done){return }arguments.callee.done=true;for(i=0;i<bkLib.domLoad.length;i++){bkLib.domLoad[i]()}},onDomLoaded:function(A){this.domLoad.push(A);if(document.addEventListener){document.addEventListener("DOMContentLoaded",bkLib.domLoaded,null)}else{if(bkLib.isMSIE){document.write("<style>.nicEdit-main p { margin: 0; }</style><script id=__ie_onload defer "+((location.protocol=="https:")?"src='javascript:void(0)'":"src=//0")+"><\/script>");$BK("__ie_onload").onreadystatechange=function(){if(this.readyState=="complete"){bkLib.domLoaded()}}}}window.onload=bkLib.domLoaded}};function $BK(A){if(typeof (A)=="string"){A=document.getElementById(A)}return(A&&!A.appendTo)?bkExtend(A,bkElement.prototype):A}var bkEvent={addEvent:function(A,B){if(B){this.eventList=this.eventList||{};this.eventList[A]=this.eventList[A]||[];this.eventList[A].push(B)}return this},fireEvent:function(){var A=bkLib.toArray(arguments),C=A.shift();if(this.eventList&&this.eventList[C]){for(var B=0;B<this.eventList[C].length;B++){this.eventList[C][B].apply(this,A)}}}};function __(A){return A}Function.prototype.closure=function(){var A=this,B=bkLib.toArray(arguments),C=B.shift();return function(){if(typeof (bkLib)!="undefined"){return A.apply(C,B.concat(bkLib.toArray(arguments)))}}};Function.prototype.closureListener=function(){var A=this,C=bkLib.toArray(arguments),B=C.shift();return function(E){E=E||window.event;if(E.target){var D=E.target}else{var D=E.srcElement}return A.apply(B,[E,D].concat(C))}};
var nicEditorConfig = bkClass.extend({
buttons : {
'bold' : {name : __('Click to Bold'), command : 'Bold', tags : ['B','STRONG'], css : {'font-weight' : 'bold'}, key : 'b'},
'italic' : {name : __('Click to Italic'), command : 'Italic', tags : ['EM','I'], css : {'font-style' : 'italic'}, key : 'i'},
'underline' : {name : __('Click to Underline'), command : 'Underline', tags : ['U'], css : {'text-decoration' : 'underline'}, key : 'u'},
'left' : {name : __('Left Align'), command : 'justifyleft', noActive : true},
'center' : {name : __('Center Align'), command : 'justifycenter', noActive : true},
'right' : {name : __('Right Align'), command : 'justifyright', noActive : true},
'justify' : {name : __('Justify Align'), command : 'justifyfull', noActive : true},
'ol' : {name : __('Insert Ordered List'), command : 'insertorderedlist', tags : ['OL']},
'ul' : {name : __('Insert Unordered List'), command : 'insertunorderedlist', tags : ['UL']},
'subscript' : {name : __('Click to Subscript'), command : 'subscript', tags : ['SUB']},
'superscript' : {name : __('Click to Superscript'), command : 'superscript', tags : ['SUP']},
'strikethrough' : {name : __('Click to Strike Through'), command : 'strikeThrough', css : {'text-decoration' : 'line-through'}},
'removeformat' : {name : __('Remove Formatting'), command : 'removeformat', noActive : true},
'indent' : {name : __('Indent Text'), command : 'indent', noActive : true},
'outdent' : {name : __('Remove Indent'), command : 'outdent', noActive : true},
'hr' : {name : __('Horizontal Rule'), command : 'insertHorizontalRule', noActive : true}
},
iconsPath : '/static/js/nicEditorIcons.gif',
buttonList : ['save','bold','italic','underline','left','center','right','justify','ol','ul','fontSize','fontFamily','fontFormat','indent','outdent','image','upload','link','unlink','forecolor','bgcolor'],
iconList : {"bgcolor":1,"forecolor":2,"bold":3,"center":4,"hr":5,"indent":6,"italic":7,"justify":8,"left":9,"ol":10,"outdent":11,"removeformat":12,"right":13,"save":24,"strikethrough":15,"subscript":16,"superscript":17,"ul":18,"underline":19,"image":20,"link":21,"unlink":22,"close":23,"arrow":25}
});
;
var nicEditors={nicPlugins:[],editors:[],registerPlugin:function(B,A){this.nicPlugins.push({p:B,o:A})},allTextAreas:function(C){var A=document.getElementsByTagName("textarea");for(var B=0;B<A.length;B++){nicEditors.editors.push(new nicEditor(C).panelInstance(A[B]))}return nicEditors.editors},findEditor:function(C){var B=nicEditors.editors;for(var A=0;A<B.length;A++){if(B[A].instanceById(C)){return B[A].instanceById(C)}}}};var nicEditor=bkClass.extend({construct:function(C){this.options=new nicEditorConfig();bkExtend(this.options,C);this.nicInstances=new Array();this.loadedPlugins=new Array();var A=nicEditors.nicPlugins;for(var B=0;B<A.length;B++){this.loadedPlugins.push(new A[B].p(this,A[B].o))}nicEditors.editors.push(this);bkLib.addEvent(document.body,"mousedown",this.selectCheck.closureListener(this))},panelInstance:function(B,C){B=this.checkReplace($BK(B));var A=new bkElement("DIV").setStyle({width:(parseInt(B.getStyle("width"))||B.clientWidth)+"px"}).appendBefore(B);this.setPanel(A);return this.addInstance(B,C)},checkReplace:function(B){var A=nicEditors.findEditor(B);if(A){A.removeInstance(B);A.removePanel()}return B},addInstance:function(B,C){B=this.checkReplace($BK(B));if(B.contentEditable||!!window.opera){var A=new nicEditorInstance(B,C,this)}else{var A=new nicEditorIFrameInstance(B,C,this)}this.nicInstances.push(A);return this},removeInstance:function(C){C=$BK(C);var B=this.nicInstances;for(var A=0;A<B.length;A++){if(B[A].e==C){B[A].remove();this.nicInstances.splice(A,1)}}},removePanel:function(A){if(this.nicPanel){this.nicPanel.remove();this.nicPanel=null}},instanceById:function(C){C=$BK(C);var B=this.nicInstances;for(var A=0;A<B.length;A++){if(B[A].e==C){return B[A]}}},setPanel:function(A){this.nicPanel=new nicEditorPanel($BK(A),this.options,this);this.fireEvent("panel",this.nicPanel);return this},nicCommand:function(B,A){if(this.selectedInstance){this.selectedInstance.nicCommand(B,A)}},getIcon:function(D,A){var C=this.options.iconList[D];var B=(A.iconFiles)?A.iconFiles[D]:"";return{backgroundImage:"url('"+((C)?this.options.iconsPath:B)+"')",backgroundPosition:((C)?((C-1)*-18):0)+"px 0px"}},selectCheck:function(C,A){var B=false;do{if(A.className&&A.className.indexOf("nicEdit")!=-1){return false}}while(A=A.parentNode);this.fireEvent("blur",this.selectedInstance,A);this.lastSelectedInstance=this.selectedInstance;this.selectedInstance=null;return false}});nicEditor=nicEditor.extend(bkEvent);
var nicEditorInstance=bkClass.extend({isSelected:false,construct:function(G,D,C){this.ne=C;this.elm=this.e=G;this.options=D||{};newX=parseInt(G.getStyle("width"))||G.clientWidth;newY=parseInt(G.getStyle("height"))||G.clientHeight;this.initialHeight=newY-8;var H=(G.nodeName.toLowerCase()=="textarea");if(H||this.options.hasPanel){var B=(bkLib.isMSIE&&!((typeof document.body.style.maxHeight!="undefined")&&document.compatMode=="CSS1Compat"));var E={width:newX+"px",border:"1px solid #ccc",borderTop:0,overflowY:"auto",overflowX:"hidden"};E[(B)?"height":"maxHeight"]=(this.ne.options.maxHeight)?this.ne.options.maxHeight+"px":null;this.editorContain=new bkElement("DIV").setStyle(E).appendBefore(G);var A=new bkElement("DIV").setStyle({width:(newX-8)+"px",margin:"4px",minHeight:newY+"px"}).addClass("main").appendTo(this.editorContain);G.setStyle({display:"none"});A.innerHTML=G.innerHTML;if(H){A.setContent(G.value);this.copyElm=G;var F=G.parentTag("FORM");if(F){bkLib.addEvent(F,"submit",this.saveContent.closure(this))}}A.setStyle((B)?{height:newY+"px"}:{overflow:"hidden"});this.elm=A}this.ne.addEvent("blur",this.blur.closure(this));this.init();this.blur()},init:function(){this.elm.setAttribute("contentEditable","true");if(this.getContent()==""){this.setContent("<br />")}this.instanceDoc=document.defaultView;this.elm.addEvent("mousedown",this.selected.closureListener(this)).addEvent("keypress",this.keyDown.closureListener(this)).addEvent("focus",this.selected.closure(this)).addEvent("blur",this.blur.closure(this)).addEvent("keyup",this.selected.closure(this));this.ne.fireEvent("add",this)},remove:function(){this.saveContent();if(this.copyElm||this.options.hasPanel){this.editorContain.remove();this.e.setStyle({display:"block"});this.ne.removePanel()}this.disable();this.ne.fireEvent("remove",this)},disable:function(){this.elm.setAttribute("contentEditable","false")},getSel:function(){return(window.getSelection)?window.getSelection():document.selection},getRng:function(){var A=this.getSel();if(!A||A.rangeCount===0){return }return(A.rangeCount>0)?A.getRangeAt(0):A.createRange()},selRng:function(A,B){if(window.getSelection){B.removeAllRanges();B.addRange(A)}else{A.select()}},selElm:function(){var C=this.getRng();if(!C){return }if(C.startContainer){var D=C.startContainer;if(C.cloneContents().childNodes.length==1){for(var B=0;B<D.childNodes.length;B++){var A=D.childNodes[B].ownerDocument.createRange();A.selectNode(D.childNodes[B]);if(C.compareBoundaryPoints(Range.START_TO_START,A)!=1&&C.compareBoundaryPoints(Range.END_TO_END,A)!=-1){return $BK(D.childNodes[B])}}}return $BK(D)}else{return $BK((this.getSel().type=="Control")?C.item(0):C.parentElement())}},saveRng:function(){this.savedRange=this.getRng();this.savedSel=this.getSel()},restoreRng:function(){if(this.savedRange){this.selRng(this.savedRange,this.savedSel)}},keyDown:function(B,A){if(B.ctrlKey){this.ne.fireEvent("key",this,B)}},selected:function(C,A){if(!A&&!(A=this.selElm)){A=this.selElm()}if(!C.ctrlKey){var B=this.ne.selectedInstance;if(B!=this){if(B){this.ne.fireEvent("blur",B,A)}this.ne.selectedInstance=this;this.ne.fireEvent("focus",B,A)}this.ne.fireEvent("selected",B,A);this.isFocused=true;this.elm.addClass("selected")}return false},blur:function(){this.isFocused=false;this.elm.removeClass("selected")},saveContent:function(){if(this.copyElm||this.options.hasPanel){this.ne.fireEvent("save",this);(this.copyElm)?this.copyElm.value=this.getContent():this.e.innerHTML=this.getContent()}},getElm:function(){return this.elm},getContent:function(){this.content=this.getElm().innerHTML;this.ne.fireEvent("get",this);return this.content},setContent:function(A){this.content=A;this.ne.fireEvent("set",this);this.elm.innerHTML=this.content},nicCommand:function(B,A){document.execCommand(B,false,A)}});
var nicEditorIFrameInstance=nicEditorInstance.extend({savedStyles:[],init:function(){var B=this.elm.innerHTML.replace(/^\s+|\s+$/g,"");this.elm.innerHTML="";(!B)?B="<br />":B;this.initialContent=B;this.elmFrame=new bkElement("iframe").setAttributes({src:"javascript:;",frameBorder:0,allowTransparency:"true",scrolling:"no"}).setStyle({height:"100px",width:"100%"}).addClass("frame").appendTo(this.elm);if(this.copyElm){this.elmFrame.setStyle({width:(this.elm.offsetWidth-4)+"px"})}var A=["font-size","font-family","font-weight","color"];for(itm in A){this.savedStyles[bkLib.camelize(itm)]=this.elm.getStyle(itm)}setTimeout(this.initFrame.closure(this),50)},disable:function(){this.elm.innerHTML=this.getContent()},initFrame:function(){var B=$BK(this.elmFrame.contentWindow.document);B.designMode="on";B.open();var A=this.ne.options.externalCSS;B.write("<html><head>"+((A)?'<link href="'+A+'" rel="stylesheet" type="text/css" />':"")+'</head><body id="nicEditContent" style="margin: 0 !important; background-color: transparent !important;">'+this.initialContent+"</body></html>");B.close();this.frameDoc=B;this.frameWin=$BK(this.elmFrame.contentWindow);this.frameContent=$BK(this.frameWin.document.body).setStyle(this.savedStyles);this.instanceDoc=this.frameWin.document.defaultView;this.heightUpdate();this.frameDoc.addEvent("mousedown",this.selected.closureListener(this)).addEvent("keyup",this.heightUpdate.closureListener(this)).addEvent("keydown",this.keyDown.closureListener(this)).addEvent("keyup",this.selected.closure(this));this.ne.fireEvent("add",this)},getElm:function(){return this.frameContent},setContent:function(A){this.content=A;this.ne.fireEvent("set",this);this.frameContent.innerHTML=this.content;this.heightUpdate()},getSel:function(){return(this.frameWin)?this.frameWin.getSelection():this.frameDoc.selection},heightUpdate:function(){this.elmFrame.style.height=Math.max(this.frameContent.offsetHeight,this.initialHeight)+"px"},nicCommand:function(B,A){this.frameDoc.execCommand(B,false,A);setTimeout(this.heightUpdate.closure(this),100)}});
var nicEditorPanel=bkClass.extend({construct:function(E,B,A){this.elm=E;this.options=B;this.ne=A;this.panelButtons=new Array();this.buttonList=bkExtend([],this.ne.options.buttonList);this.panelContain=new bkElement("DIV").setStyle({overflow:"hidden",width:"100%",border:"1px solid #cccccc",backgroundColor:"#efefef"}).addClass("panelContain");this.panelElm=new bkElement("DIV").setStyle({margin:"2px",marginTop:"0px",zoom:1,overflow:"hidden"}).addClass("panel").appendTo(this.panelContain);this.panelContain.appendTo(E);var C=this.ne.options;var D=C.buttons;for(button in D){this.addButton(button,C,true)}this.reorder();E.noSelect()},addButton:function(buttonName,options,noOrder){var button=options.buttons[buttonName];var type=(button.type)?eval("(typeof("+button.type+') == "undefined") ? null : '+button.type+";"):nicEditorButton;var hasButton=bkLib.inArray(this.buttonList,buttonName);if(type&&(hasButton||this.ne.options.fullPanel)){this.panelButtons.push(new type(this.panelElm,buttonName,options,this.ne));if(!hasButton){this.buttonList.push(buttonName)}}},findButton:function(B){for(var A=0;A<this.panelButtons.length;A++){if(this.panelButtons[A].name==B){return this.panelButtons[A]}}},reorder:function(){var C=this.buttonList;for(var B=0;B<C.length;B++){var A=this.findButton(C[B]);if(A){this.panelElm.appendChild(A.margin)}}},remove:function(){this.elm.remove()}});
var nicEditorButton=bkClass.extend({construct:function(D,A,C,B){this.options=C.buttons[A];this.name=A;this.ne=B;this.elm=D;this.margin=new bkElement("DIV").setStyle({"float":"left",marginTop:"2px"}).appendTo(D);this.contain=new bkElement("DIV").setStyle({width:"20px",height:"20px"}).addClass("buttonContain").appendTo(this.margin);this.border=new bkElement("DIV").setStyle({backgroundColor:"#efefef",border:"1px solid #efefef"}).appendTo(this.contain);this.button=new bkElement("DIV").setStyle({width:"18px",height:"18px",overflow:"hidden",zoom:1,cursor:"pointer"}).addClass("button").setStyle(this.ne.getIcon(A,C)).appendTo(this.border);this.button.addEvent("mouseover",this.hoverOn.closure(this)).addEvent("mouseout",this.hoverOff.closure(this)).addEvent("mousedown",this.mouseClick.closure(this)).noSelect();if(!window.opera){this.button.onmousedown=this.button.onclick=bkLib.cancelEvent}B.addEvent("selected",this.enable.closure(this)).addEvent("blur",this.disable.closure(this)).addEvent("key",this.key.closure(this));this.disable();this.init()},init:function(){},hide:function(){this.contain.setStyle({display:"none"})},updateState:function(){if(this.isDisabled){this.setBg()}else{if(this.isHover){this.setBg("hover")}else{if(this.isActive){this.setBg("active")}else{this.setBg()}}}},setBg:function(A){switch(A){case"hover":var B={border:"1px solid #666",backgroundColor:"#ddd"};break;case"active":var B={border:"1px solid #666",backgroundColor:"#ccc"};break;default:var B={border:"1px solid #efefef",backgroundColor:"#efefef"}}this.border.setStyle(B).addClass("button-"+A)},checkNodes:function(A){var B=A;do{if(this.options.tags&&bkLib.inArray(this.options.tags,B.nodeName)){this.activate();return true}}while(B=B.parentNode&&B.className!="nicEdit");B=$BK(A);while(B.nodeType==3){B=$BK(B.parentNode)}if(this.options.css){for(itm in this.options.css){if(B.getStyle(itm,this.ne.selectedInstance.instanceDoc)==this.options.css[itm]){this.activate();return true}}}this.deactivate();return false},activate:function(){if(!this.isDisabled){this.isActive=true;this.updateState();this.ne.fireEvent("buttonActivate",this)}},deactivate:function(){this.isActive=false;this.updateState();if(!this.isDisabled){this.ne.fireEvent("buttonDeactivate",this)}},enable:function(A,B){this.isDisabled=false;this.contain.setStyle({opacity:1}).addClass("buttonEnabled");this.updateState();this.checkNodes(B)},disable:function(A,B){this.isDisabled=true;this.contain.setStyle({opacity:0.6}).removeClass("buttonEnabled");this.updateState()},toggleActive:function(){(this.isActive)?this.deactivate():this.activate()},hoverOn:function(){if(!this.isDisabled){this.isHover=true;this.updateState();this.ne.fireEvent("buttonOver",this)}},hoverOff:function(){this.isHover=false;this.updateState();this.ne.fireEvent("buttonOut",this)},mouseClick:function(){if(this.options.command){this.ne.nicCommand(this.options.command,this.options.commandArgs);if(!this.options.noActive){this.toggleActive()}}this.ne.fireEvent("buttonClick",this)},key:function(A,B){if(this.options.key&&B.ctrlKey&&String.fromCharCode(B.keyCode||B.charCode).toLowerCase()==this.options.key){this.mouseClick();if(B.preventDefault){B.preventDefault()}}}});
var nicPlugin=bkClass.extend({construct:function(B,A){this.options=A;this.ne=B;this.ne.addEvent("panel",this.loadPanel.closure(this));this.init()},loadPanel:function(C){var B=this.options.buttons;for(var A in B){C.addButton(A,this.options)}C.reorder()},init:function(){}});
var nicPaneOptions = { };
var nicEditorPane=bkClass.extend({construct:function(D,C,B,A){this.ne=C;this.elm=D;this.pos=D.pos();this.contain=new bkElement("div").setStyle({zIndex:"99999",overflow:"hidden",position:"absolute",left:this.pos[0]+"px",top:this.pos[1]+"px"});this.pane=new bkElement("div").setStyle({fontSize:"12px",border:"1px solid #ccc",overflow:"hidden",padding:"4px",textAlign:"left",backgroundColor:"#ffffc9"}).addClass("pane").setStyle(B).appendTo(this.contain);if(A&&!A.options.noClose){this.close=new bkElement("div").setStyle({"float":"right",height:"16px",width:"16px",cursor:"pointer"}).setStyle(this.ne.getIcon("close",nicPaneOptions)).addEvent("mousedown",A.removePane.closure(this)).appendTo(this.pane)}this.contain.noSelect().appendTo(document.body);this.position();this.init()},init:function(){},position:function(){if(this.ne.nicPanel){var B=this.ne.nicPanel.elm;var A=B.pos();var C=A[0]+parseInt(B.getStyle("width"))-(parseInt(this.pane.getStyle("width"))+8);if(C<this.pos[0]){this.contain.setStyle({left:C+"px"})}}},toggle:function(){this.isVisible=!this.isVisible;this.contain.setStyle({display:((this.isVisible)?"block":"none")})},remove:function(){if(this.contain){this.contain.remove();this.contain=null}},append:function(A){A.appendTo(this.pane)},setContent:function(A){this.pane.setContent(A)}});
var nicEditorAdvancedButton=nicEditorButton.extend({init:function(){this.ne.addEvent("selected",this.removePane.closure(this)).addEvent("blur",this.removePane.closure(this))},mouseClick:function(){if(!this.isDisabled){if(this.pane&&this.pane.pane){this.removePane()}else{this.pane=new nicEditorPane(this.contain,this.ne,{width:(this.width||"270px"),backgroundColor:"#fff"},this);this.addPane();this.ne.selectedInstance.saveRng()}}},addForm:function(C,G){this.form=new bkElement("form").addEvent("submit",this.submit.closureListener(this));this.pane.append(this.form);this.inputs={};for(itm in C){var D=C[itm];var F="";if(G){F=G.getAttribute(itm)}if(!F){F=D.value||""}var A=C[itm].type;if(A=="title"){new bkElement("div").setContent(D.txt).setStyle({fontSize:"14px",fontWeight:"bold",padding:"0px",margin:"2px 0"}).appendTo(this.form)}else{var B=new bkElement("div").setStyle({overflow:"hidden",clear:"both"}).appendTo(this.form);if(D.txt){new bkElement("label").setAttributes({"for":itm}).setContent(D.txt).setStyle({margin:"2px 4px",fontSize:"13px",width:"50px",lineHeight:"20px",textAlign:"right","float":"left"}).appendTo(B)}switch(A){case"text":this.inputs[itm]=new bkElement("input").setAttributes({id:itm,value:F,type:"text"}).setStyle({margin:"2px 0",fontSize:"13px","float":"left",height:"20px",border:"1px solid #ccc",overflow:"hidden"}).setStyle(D.style).appendTo(B);break;case"select":this.inputs[itm]=new bkElement("select").setAttributes({id:itm}).setStyle({border:"1px solid #ccc","float":"left",margin:"2px 0"}).appendTo(B);for(opt in D.options){var E=new bkElement("option").setAttributes({value:opt,selected:(opt==F)?"selected":""}).setContent(D.options[opt]).appendTo(this.inputs[itm])}break;case"content":this.inputs[itm]=new bkElement("textarea").setAttributes({id:itm}).setStyle({border:"1px solid #ccc","float":"left"}).setStyle(D.style).appendTo(B);this.inputs[itm].value=F}}}new bkElement("input").setAttributes({type:"submit"}).setStyle({backgroundColor:"#efefef",border:"1px solid #ccc",margin:"3px 0","float":"left",clear:"both"}).appendTo(this.form);this.form.onsubmit=bkLib.cancelEvent},submit:function(){},findElm:function(B,A,E){var D=this.ne.selectedInstance.getElm().getElementsByTagName(B);for(var C=0;C<D.length;C++){if(D[C].getAttribute(A)==E){return $BK(D[C])}}},removePane:function(){if(this.pane){this.pane.remove();this.pane=null;this.ne.selectedInstance.restoreRng()}}});
var nicButtonTips=bkClass.extend({construct:function(A){this.ne=A;A.addEvent("buttonOver",this.show.closure(this)).addEvent("buttonOut",this.hide.closure(this))},show:function(A){this.timer=setTimeout(this.create.closure(this,A),400)},create:function(A){this.timer=null;if(!this.pane){this.pane=new nicEditorPane(A.button,this.ne,{fontSize:"12px",marginTop:"5px"});this.pane.setContent(A.options.name)}},hide:function(A){if(this.timer){clearTimeout(this.timer)}if(this.pane){this.pane=this.pane.remove()}}});nicEditors.registerPlugin(nicButtonTips);
var nicSelectOptions = {
buttons : {
'fontSize' : {name : __('Select Font Size'), type : 'nicEditorFontSizeSelect', command : 'fontsize'},
'fontFamily' : {name : __('Select Font Family'), type : 'nicEditorFontFamilySelect', command : 'fontname'},
'fontFormat' : {name : __('Select Font Format'), type : 'nicEditorFontFormatSelect', command : 'formatBlock'}
}
};
var nicEditorSelect=bkClass.extend({construct:function(D,A,C,B){this.options=C.buttons[A];this.elm=D;this.ne=B;this.name=A;this.selOptions=new Array();this.margin=new bkElement("div").setStyle({"float":"left",margin:"2px 1px 0 1px"}).appendTo(this.elm);this.contain=new bkElement("div").setStyle({width:"90px",height:"20px",cursor:"pointer",overflow:"hidden"}).addClass("selectContain").addEvent("click",this.toggle.closure(this)).appendTo(this.margin);this.items=new bkElement("div").setStyle({overflow:"hidden",zoom:1,border:"1px solid #ccc",paddingLeft:"3px",backgroundColor:"#fff"}).appendTo(this.contain);this.control=new bkElement("div").setStyle({overflow:"hidden","float":"right",height:"18px",width:"16px"}).addClass("selectControl").setStyle(this.ne.getIcon("arrow",C)).appendTo(this.items);this.txt=new bkElement("div").setStyle({overflow:"hidden","float":"left",width:"66px",height:"14px",marginTop:"1px",fontFamily:"sans-serif",textAlign:"center",fontSize:"12px"}).addClass("selectTxt").appendTo(this.items);if(!window.opera){this.contain.onmousedown=this.control.onmousedown=this.txt.onmousedown=bkLib.cancelEvent}this.margin.noSelect();this.ne.addEvent("selected",this.enable.closure(this)).addEvent("blur",this.disable.closure(this));this.disable();this.init()},disable:function(){this.isDisabled=true;this.close();this.contain.setStyle({opacity:0.6})},enable:function(A){this.isDisabled=false;this.close();this.contain.setStyle({opacity:1})},setDisplay:function(A){this.txt.setContent(A)},toggle:function(){if(!this.isDisabled){(this.pane)?this.close():this.open()}},open:function(){this.pane=new nicEditorPane(this.items,this.ne,{width:"88px",padding:"0px",borderTop:0,borderLeft:"1px solid #ccc",borderRight:"1px solid #ccc",borderBottom:"0px",backgroundColor:"#fff"});for(var C=0;C<this.selOptions.length;C++){var B=this.selOptions[C];var A=new bkElement("div").setStyle({overflow:"hidden",borderBottom:"1px solid #ccc",width:"88px",textAlign:"left",overflow:"hidden",cursor:"pointer"});var D=new bkElement("div").setStyle({padding:"0px 4px"}).setContent(B[1]).appendTo(A).noSelect();D.addEvent("click",this.update.closure(this,B[0])).addEvent("mouseover",this.over.closure(this,D)).addEvent("mouseout",this.out.closure(this,D)).setAttributes("id",B[0]);this.pane.append(A);if(!window.opera){D.onmousedown=bkLib.cancelEvent}}},close:function(){if(this.pane){this.pane=this.pane.remove()}},over:function(A){A.setStyle({backgroundColor:"#ccc"})},out:function(A){A.setStyle({backgroundColor:"#fff"})},add:function(B,A){this.selOptions.push(new Array(B,A))},update:function(A){this.ne.nicCommand(this.options.command,A);this.close()}});var nicEditorFontSizeSelect=nicEditorSelect.extend({sel:{1:"1&nbsp;(8pt)",2:"2&nbsp;(10pt)",3:"3&nbsp;(12pt)",4:"4&nbsp;(14pt)",5:"5&nbsp;(18pt)",6:"6&nbsp;(24pt)"},init:function(){this.setDisplay("Font&nbsp;Size...");for(itm in this.sel){this.add(itm,'<font size="'+itm+'">'+this.sel[itm]+"</font>")}}});var nicEditorFontFamilySelect=nicEditorSelect.extend({sel:{arial:"Arial","comic sans ms":"Comic Sans","courier new":"Courier New",georgia:"Georgia",helvetica:"Helvetica",impact:"Impact","times new roman":"Times","trebuchet ms":"Trebuchet",verdana:"Verdana"},init:function(){this.setDisplay("Font&nbsp;Family...");for(itm in this.sel){this.add(itm,'<font face="'+itm+'">'+this.sel[itm]+"</font>")}}});var nicEditorFontFormatSelect=nicEditorSelect.extend({sel:{p:"Paragraph",pre:"Pre",h6:"Heading&nbsp;6",h5:"Heading&nbsp;5",h4:"Heading&nbsp;4",h3:"Heading&nbsp;3",h2:"Heading&nbsp;2",h1:"Heading&nbsp;1"},init:function(){this.setDisplay("Font&nbsp;Format...");for(itm in this.sel){var A=itm.toUpperCase();this.add("<"+A+">","<"+itm+' style="padding: 0px; margin: 0px;">'+this.sel[itm]+"</"+A+">")}}});nicEditors.registerPlugin(nicPlugin,nicSelectOptions);
var nicLinkOptions = {
buttons : {
'link' : {name : 'Add Link', type : 'nicLinkButton', tags : ['A']},
'unlink' : {name : 'Remove Link', command : 'unlink', noActive : true}
}
};
var nicLinkButton=nicEditorAdvancedButton.extend({addPane:function(){this.ln=this.ne.selectedInstance.selElm().parentTag("A");this.addForm({"":{type:"title",txt:"Add/Edit Link"},href:{type:"text",txt:"URL",value:"http://",style:{width:"150px"}},title:{type:"text",txt:"Title"},target:{type:"select",txt:"Open In",options:{"":"Current Window",_blank:"New Window"},style:{width:"100px"}}},this.ln)},submit:function(C){var A=this.inputs.href.value;if(A=="http://"||A==""){alert("You must enter a URL to Create a Link");return false}this.removePane();if(!this.ln){var B="javascript:nicTemp();";this.ne.nicCommand("createlink",B);this.ln=this.findElm("A","href",B)}if(this.ln){this.ln.setAttributes({href:this.inputs.href.value,title:this.inputs.title.value,target:this.inputs.target.options[this.inputs.target.selectedIndex].value})}}});nicEditors.registerPlugin(nicPlugin,nicLinkOptions);
var nicColorOptions = {
buttons : {
'forecolor' : {name : __('Change Text Color'), type : 'nicEditorColorButton', noClose : true},
'bgcolor' : {name : __('Change Background Color'), type : 'nicEditorBgColorButton', noClose : true}
}
};
var nicEditorColorButton=nicEditorAdvancedButton.extend({addPane:function(){var D={0:"00",1:"33",2:"66",3:"99",4:"CC",5:"FF"};var H=new bkElement("DIV").setStyle({width:"270px"});for(var A in D){for(var F in D){for(var E in D){var I="#"+D[A]+D[E]+D[F];var C=new bkElement("DIV").setStyle({cursor:"pointer",height:"15px","float":"left"}).appendTo(H);var G=new bkElement("DIV").setStyle({border:"2px solid "+I}).appendTo(C);var B=new bkElement("DIV").setStyle({backgroundColor:I,overflow:"hidden",width:"11px",height:"11px"}).addEvent("click",this.colorSelect.closure(this,I)).addEvent("mouseover",this.on.closure(this,G)).addEvent("mouseout",this.off.closure(this,G,I)).appendTo(G);if(!window.opera){C.onmousedown=B.onmousedown=bkLib.cancelEvent}}}}this.pane.append(H.noSelect())},colorSelect:function(A){this.ne.nicCommand("foreColor",A);this.removePane()},on:function(A){A.setStyle({border:"2px solid #000"})},off:function(A,B){A.setStyle({border:"2px solid "+B})}});var nicEditorBgColorButton=nicEditorColorButton.extend({colorSelect:function(A){this.ne.nicCommand("hiliteColor",A);this.removePane()}});nicEditors.registerPlugin(nicPlugin,nicColorOptions);
var nicImageOptions = {
buttons : {
'image' : {name : 'Add Image', type : 'nicImageButton', tags : ['IMG']}
}
};
var nicImageButton=nicEditorAdvancedButton.extend({addPane:function(){this.im=this.ne.selectedInstance.selElm().parentTag("IMG");this.addForm({"":{type:"title",txt:"Add/Edit Image"},src:{type:"text",txt:"URL",value:"http://",style:{width:"150px"}},alt:{type:"text",txt:"Alt Text",style:{width:"100px"}},align:{type:"select",txt:"Align",options:{none:"Default",left:"Left",right:"Right"}}},this.im)},submit:function(B){var C=this.inputs.src.value;if(C==""||C=="http://"){alert("You must enter a Image URL to insert");return false}this.removePane();if(!this.im){var A="javascript:nicImTemp();";this.ne.nicCommand("insertImage",A);this.im=this.findElm("IMG","src",A)}if(this.im){this.im.setAttributes({src:this.inputs.src.value,alt:this.inputs.alt.value,align:this.inputs.align.value})}}});nicEditors.registerPlugin(nicPlugin,nicImageOptions);
var nicSaveOptions = {
buttons : {
'save' : {name : __('Save this content'), type : 'nicEditorSaveButton'}
}
};
var nicEditorSaveButton=nicEditorButton.extend({init:function(){if(!this.ne.options.onSave){this.margin.setStyle({display:"none"})}},mouseClick:function(){var B=this.ne.options.onSave;var A=this.ne.selectedInstance;B(A.getContent(),A.elm.id,A)}});nicEditors.registerPlugin(nicPlugin,nicSaveOptions);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1,9 +0,0 @@
{% extends "_layout.jinja2" %}
{% block header %}
<h1>404</h1>
{% endblock header %}
{% block main %}
<p>Ce n'est pas la page que vous cherchez.</p>
{% endblock main %}

View File

@ -1,64 +0,0 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<link rel="icon" href="{{ url_for('static', filename='images/favicon.ico') }}" />
<title>AFPY - Le site web de l'Association Francophone de Python</title>
<link type="text/css" rel="stylesheet" href="{{ url_for('static', filename='css/style.sass.css') }}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
{% block script %}{% endblock script %}
</head>
<body id="{{ body_id }}">
<header>
<div class="wrapper">
{% block header %}{% endblock header %}
</div>
</header>
<nav class="wrapper menu menu--main">
{% set navigation_bar = [
(url_for('index'), 'index', 'Accueil'),
(url_for('rest', name='a-propos'), 'a-propos', 'Qui sommes-nous ?'),
(url_for('posts', name='actualites'), 'actualites', 'Actualités'),
(url_for('posts', name='emplois'), 'emplois', 'Offres d\'emplois'),
(url_for('communaute'), 'communaute', 'Communauté'),
('https://discuss.afpy.org', 'discussion', 'Discussion'),
(url_for('irc'), 'irc', 'IRC'),
('https://afpy.org/discord', 'discord', 'Discord'),
(url_for('adhesions'), 'adhesions', 'Adhésions')
] -%}
<label for="toggle" class="menu__toggle">Menu</label>
<input class="menu__checkbox" type="checkbox" name="toggle" id="toggle" checked>
<ul class="menu__list">
<li class="menu__item menu__item--brand">
<a class="brand" href="{{ url_for('index') }}">
<img src="{{ url_for('static', filename='images/logo.svg') }}" title="Logo afpy" />
<span>AFPy</span>
</a>
</li>
{% for href, id, caption in navigation_bar %}
<li class="menu__item{% if id == body_id %} active{% endif
%}"><a href="{{ href|e }}">{{ caption|e }}</a></li>
{% endfor %}
</ul>
</nav>
<main>
{% block main %}{% endblock main %}
</main>
<footer class="wrapper menu menu--footer">
{% set navigation_bar = [
(url_for('rest', name='contact'), 'Contact'),
(url_for('rest', name='charte'), 'Charte'),
(url_for('rest', name='legal'), 'Mentions légales'),
(url_for('rest', name='rss'), 'Flux RSS'),
('https://twitter.com/asso_python_fr', 'Twitter'),
(url_for('admin', name='actualites'), 'Admin actualités'),
(url_for('admin', name='emplois'), 'Admin offres d\'emploi')
] -%}
<ul class="menu__list">
{% for href, caption in navigation_bar %}
<li class="menu__item"><a href="{{ href|e }}">{{ caption|e }}</a></li>
{% endfor %}
</ul>
</footer>
</body>
</html>

View File

@ -1,10 +0,0 @@
{% extends '_layout.jinja2' %}
{% block header %}
<h1>Adhésions</h1>
{% endblock header %}
{% block main %}
<h2>Adhérez à l'AFPy</h2>
<iframe id="haWidget" src="https://www.helloasso.com/associations/afpy/adhesions/adhesion-2021-a-l-afpy/widget"></iframe>
{% endblock main %}

View File

@ -1,43 +0,0 @@
{% extends '_layout.jinja2' %}
{% block header %}
<h1>Administration</h1>
{% endblock header %}
{% block main %}
<article>
<h2>{{ label }}</h2>
{% for state, timestamps in posts.items() %}
{% if state == 'waiting' %}
{% set state_label= 'En attente' %}
{% elif state == 'published' %}
{% set state_label= 'Publiés' %}
{% else %}
{% set state_label= 'Supprimés' %}
{% endif %}
<h3>{{ state_label }}</h3>
{% if timestamps %}
<table>
<thead>
<tr>
<th>Titre</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for timestamp, post in timestamps.items() %}
<tr>
<td>{{ post.title }}</td>
<td>{{ post.published | parse_iso_datetime('%x') }}</td>
<td><a href="{{ url_for('edit_post_admin', name=name, timestamp=timestamp) }}">Éditer</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>Aucun article.</p>
{% endif %}
{% endfor %}
</article>
{% endblock main %}

View File

@ -1,48 +0,0 @@
{% extends "_layout.jinja2" %}
{% block header %}
<h1>Communauté</h1>
{% endblock header %}
{% block main %}
<h2>Forum de discussion</h2>
<p>
Afin d'échanger avec la communauté, un forum de discussion est disponible et
traite de tous les sujets autour de Python.
</p>
<p>
<a href="https://discuss.afpy.org/">Forum</a>
</p>
<h2>Rencontres</h2>
<p>
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.
</p>
<ul>
{% for city, url in meetups.items() %}
<li><a href="{{ url }}">{{ city | capitalize }}</a></li>
{% endfor %}
</ul>
<h2>PyConFr</h2>
<p>
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.
</p>
<p>
<a href="https://www.pycon.fr/">PyConFr</a>
</p>
<h2>April</h2>
<p>
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.
</p>
<p>
<a href="http://april.org/campagne/">April</a>
</p>
{% endblock main %}

View File

@ -1,31 +0,0 @@
{% extends '_layout.jinja2' %}
{% block header %}
<h1>Confirmation de l'enregistrement de l'article</h1>
{% endblock header %}
{% block main %}
<h2>Merci de votre participation</h2>
<p>
Votre article a bien été enregistré. Il sera mis en ligne après acceptation
de l'un des modérateurs.
</p>
<p>
En attendant, vous pouvez toujours la modifier en utilisant le lien :
<a class="case-sensitive" href="{{ edit_post_url }}">{{ edit_post_url }}</a>.
Attention à conserver celui-ci secret !
</p>
<h2>Demande d'informations complémentaires</h2>
<p>
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 <a class="reference external" href="/discussion">Discussion</a>.
</p>
{% endblock main %}

View File

@ -1,82 +0,0 @@
{% extends '_layout.jinja2' %}
{% block script %}
<script type="text/javascript" src="{{ url_for('static', filename='js/nicEdit.js') }}"></script>
<script type="text/javascript">
bkLib.onDomLoaded(function() {
new nicEditor({
buttonList : ['fontFormat','bold','italic','underline','strikeThrough','subscript','superscript','link','unlink','xhtml']
}).panelInstance('content');
});
</script>
{% endblock script %}
{% block header %}
<h1>
{% if post %}
Modification d'un article
{% else %}
Création d'un article
{% endif %}
</h1>
{% endblock header %}
{% block main %}
<article>
<form method="post" enctype="multipart/form-data">
<label>Titre
<input name="title" value="{{ post.title }}" />
</label>
<label>Description
<textarea name="summary">{{ post.summary }}</textarea>
</label>
<label>Image
{% if post._image %}
<img alt="" src="{{ url_for('post_image', path=post._image) }}" />
<input name="_image_path" id="_image_path" value="{{ post._image }}" type="hidden"/>
<input type="submit" name="delete_image" value="Supprimer l'image" class="button" />
{% endif %}
<input name="image" id="image" type="file" value="{{ post.image }}" />
</label>
<label>Contenu de l'article
<textarea name="content" id="content">{{ post.content }}</textarea>
</label>
{% if name == 'emplois' %}
<label>Entreprise
<input name="company" value="{{ post.company }}" />
</label>
<label>Adresse
<input name="address" value="{{ post.address }}" />
</label>
<label>Personne à contacter
<input name="contact" value="{{ post.contact }}" />
</label>
<label>Téléphone
<input name="phone" value="{{ post.phone }}" />
</label>
{% endif %}
<label>Adresse e-mail
<input name="email" type="email" value="{{ post.email }}" />
</label>
<input type="submit" name="edit" value="Enregistrer" />
{% if admin %}
{% if post._state == 'waiting' %}
<input type="submit" name="publish" value="Publier" />
{% elif post._state == 'published' %}
<input type="submit" name="unpublish" value="Dépublier" />
{% else %}
<input type="submit" name="republish" value="Republier" />
{% endif %}
{% endif %}
{% if post._state == 'published' %}
<input type="submit" name="trash" value="Supprimer" />
{% endif %}
</form>
{% if name == 'actualites' %}
<p>
L'adresse e-mail n'est pas rendue publique, elle est uniquement
utilisée par les modérateurs pour vous contacter si nécessaire.
</p>
{% endif %}
</article>
{% endblock main %}

View File

@ -1,38 +0,0 @@
{% extends '_layout.jinja2' %}
{% block header %}
<h1><abbr>AFPy</abbr> Association Francophone Python</h1>
{% endblock header %}
{% block main %}
<h2>AFPy</h2>
<p>
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 <a href="{{ url_for('communaute') }}">évènements</a> sont organisés régulièrement au niveau local et d'autres évènements à un niveau plus général.
</p>
<h2>Adhérer</h2>
<p>
Il est possible de soutenir le développement de l'AFPy en cotisant ou en effectuant un don.
</p>
<form action="{{ url_for('adhesions') }}">
<input type="submit" value="S'inscrire" class="button" />
</form>
<h2>Actualités</h2>
<section id="index-news">
{% for timestamp, post in posts.items() %}
<article>
<h3>{{ post.title }}</h3>
<time pubdate datetime="{{ post.published }}">
{{ post.published | parse_iso_datetime('%x') }}
</time>
{% if post._image %}
<img src="{{ url_for('post_image', path=post._image) }}" alt="{{ post.title }}" />
{% endif %}
{{ post.summary | safe }}
<p><a href="{{ url_for('post', name=name, timestamp=timestamp) }}">Lire la suite…</a></p>
</article>
{% endfor %}
</section>
{% endblock main %}

View File

@ -1,47 +0,0 @@
{% extends "_layout.jinja2" %}
{% block header %}
<h1>Discussion</h1>
{% endblock header %}
{% block main %}
<p>Depuis cette page web vous pouvez</p>
<ul>
<li>venir discuter avec l'association sur le canal <a href="#tchatafpy">#afpy</a></li>
<li>parler Python en français sur le canal <a href="#tchatpython">#python-fr</a></li>
<li>accéder aux <a href="https://lists.afpy.org/mailman/listinfo/">Mailing Lists</a></li>
</ul>
<h2>Bon à savoir</h2>
<p>
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 :
<code>/join #python-fr</code>
</p>
<p>
Si vous souhaitez changer de surnom après connexion :
<code>/nick nouveaunom</code>
</p>
<h2 id="tchatafpy">Discuter avec l'AFPy (organisation de la communauté)</h2>
<iframe src="https://web.libera.chat/#afpy"></iframe>
<p>
Vous pouvez aussi accéder au T'chat via un client IRC : <a href="irc://irc.libera.chat/afpy">irc://irc.libera.chat/afpy</a>.
Nous stockons <a href="http://logs.afpy.org/">les archives IRC du canal #afpy</a>.
</p>
<h2 id="tchatpython">Discuter autour de Python</h2>
<iframe src="https://web.libera.chat/#python-fr"></iframe>
<p>
Vous pouvez aussi accéder au T'chat via un client IRC : <a href="irc://irc.libera.chat/python-fr">irc://irc.libera.chat/python-fr</a>.
</p>
{% endblock main %}

View File

@ -1,45 +0,0 @@
{% extends '_layout.jinja2' %}
{% block header %}
<h1>{{ post.title }}</h1>
{% endblock header %}
{% block main %}
<article>
<time pubdate datetime="{{ post.published }}">
Posté le {{ post.published | parse_iso_datetime('%x') }}
</time>
<p>
<em>
{{ post.summary | safe if post.summary }}
</em>
</p>
{% if post._image %}
<img src="{{ url_for('post_image', path=post._image) }}" alt="{{ post.title }}" />
{% endif %}
{{ post.content | safe }}
</article>
{% if name == 'emplois' %}
<aside>
<h2>{{ post.company or "(Société inconnue)" }}</h2>
<dl>
{% if post.address %}
<dt>Adresse</dt>
<dd>{{ post.address }}</dd>
{% endif %}
{% if post.contact %}
<dt>Personne à contacter</dt>
<dd>{{ post.contact }}</dd>
{% endif %}
{% if post.phone %}
<dt>Téléphone</dt>
<dd><a href="tel:{{ post.phone }}">{{ post.phone }}</a></dd>
{% endif %}
{% if post.email %}
<dt>Adresse e-mail</dt>
<dd><a href="mailto:{{ post.email }}">{{ post.email }}</a></dd>
{% endif %}
</dl>
</aside>
{% endif %}
{% endblock main %}

View File

@ -1,34 +0,0 @@
{% extends '_layout.jinja2' %}
{% block header %}
<h1>{{ title }}</h1>
{% endblock header %}
{% block main %}
<aside>
Vous pouvez <a href="{{ url_for('edit_post', name=name) }}">créer un article</a> qui
sera mis en ligne après acceptation de l'un des modérateurs.
</aside>
{% for timestamp, post in posts.items() %}
<article>
<h2>{{ post.title }}</h2>
<time pubdate datetime="{{ post.published }}">
{{ post.published | parse_iso_datetime('%x') }}
</time>
{% if post._image %}
<img src="{{ url_for('post_image', path=post._image) }}" alt="{{ post.title }}" />
{% endif %}
{{ post.summary | safe if post.summary }}
<p><a href="{{ url_for('post', name=name, timestamp=timestamp) }}">Lire la suite…</a></p>
</article>
{% endfor %}
<aside>
{% if page != 1 %}
<a href="{{ url_for('posts', name=name, page=page-1) }}">Précedente</a>
{% endif %}
Page {{ page }}/{{ total_pages }}
{% if page != total_pages %}
<a href="{{ url_for('posts', name=name, page=page+1) }}">Suivante</a>
{% endif %}
</aside>
{% endblock main %}

View File

@ -1,9 +0,0 @@
{% extends '_layout.jinja2' %}
{% block header %}
<h1>{{ title }}</h1>
{% endblock header %}
{% block main %}
{{ html | safe }}
{% endblock main %}

View File

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

139
xml2sql.py Normal file
View File

@ -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 demploi"}
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)