Backend Architecture redo (#58)
This commit is contained in:
parent
7fb2b17721
commit
9df32984cb
|
@ -0,0 +1,5 @@
|
|||
FLASK_PORT=5000
|
||||
FLASK_DEBUG=false
|
||||
FLASK_HOST=localhost
|
||||
FLASK_SECRET_KEY=ThisIsADevelopmentKey
|
||||
DB_NAME=afpy.db
|
4
.flake8
4
.flake8
|
@ -1,2 +1,4 @@
|
|||
[flake8]
|
||||
max-line-length = 88
|
||||
max-line-length = 120
|
||||
exclude = venv/*
|
||||
ignore = E402, W291, W503
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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/
|
||||
|
|
10
.isort.cfg
10
.isort.cfg
|
@ -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
|
|
@ -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]
|
18
Makefile
18
Makefile
|
@ -1,4 +1,4 @@
|
|||
VENV = $(PWD)/.env
|
||||
VENV = $(PWD)/venv
|
||||
PIP = $(VENV)/bin/pip
|
||||
PYTHON = $(VENV)/bin/python
|
||||
FLASK = $(VENV)/bin/flask
|
||||
|
@ -9,26 +9,18 @@ all: install serve
|
|||
|
||||
install:
|
||||
test -d $(VENV) || python3 -m venv $(VENV)
|
||||
$(PIP) install --upgrade --no-cache pip setuptools -e .[test]
|
||||
$(PIP) install --upgrade --no-cache pip setuptools -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
clean:
|
||||
rm -fr dist
|
||||
rm -fr $(VENV)
|
||||
rm -fr *.egg-info
|
||||
|
||||
check-outdated:
|
||||
$(PIP) list --outdated --format=columns
|
||||
|
||||
test:
|
||||
$(PYTHON) -m pytest tests.py afpy.py --flake8 --isort --cov=afpy --cov=tests --cov-report=term-missing
|
||||
$(PYTHON) -m pytest tests.py afpy/ --flake8 --black --cov=afpy --cov=tests --cov-report=term-missing
|
||||
|
||||
serve:
|
||||
env FLASK_APP=afpy.py FLASK_ENV=development $(FLASK) run
|
||||
$(PYTHON) run.py
|
||||
|
||||
isort:
|
||||
$(ISORT) -rc .isort.cfg afpy.py tests.py
|
||||
|
||||
black:
|
||||
$(VENV)/bin/black afpy.py tests.py
|
||||
|
||||
.PHONY: all install clean check-outdated test serve isort black
|
||||
.PHONY: all install clean check-outdated test serve
|
||||
|
|
21
README.md
21
README.md
|
@ -5,24 +5,27 @@ Site Web de l'AFPy.
|
|||
|
||||
## Tester localement
|
||||
|
||||
Un `make install` suivi d'un `make serve` suffit pour tester
|
||||
localement sans article.
|
||||
Commencez par un `make install`
|
||||
|
||||
Ensuite, `mv .env.template .env` en remplacant les valeurs
|
||||
nécéssaires.
|
||||
|
||||
Créez le répertoire des images: `images` à la racine du projet (ou
|
||||
ailleurs, configuré via `IMAGE_PATHS` dans le `.env`).
|
||||
|
||||
Puis un `make serve` suffit pour tester localement sans article.
|
||||
|
||||
Si vous voulez des articles, lancez un `tar xjf posts.tar.bz2`
|
||||
d'abord.
|
||||
|
||||
Si vous avez votre propre venv, un `FLASK_APP=afpy.py
|
||||
FLASK_ENV=development flask run` vous suffira.
|
||||
d'abord, puis un `python xml2sql.py` ce qui remplira la DB
|
||||
|
||||
/!\ the default admin user is `admin:password
|
||||
|
||||
## Tester
|
||||
|
||||
```bash
|
||||
pip install -e.[test]
|
||||
make test # ou make test VENV="$VIRTUAL_ENV" pour utiliser votre venv.
|
||||
make test
|
||||
```
|
||||
|
||||
|
||||
## Déployer
|
||||
|
||||
Pour publier il suffit de `git push`, une action github s'occupe de la mise en prod.
|
||||
|
|
340
afpy.py
340
afpy.py
|
@ -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()])
|
|
@ -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)
|
|
@ -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/")
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)")
|
|
@ -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,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()])
|
|
@ -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()
|
|
@ -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()
|
|
@ -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()
|
|
@ -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()
|
|
@ -0,0 +1,8 @@
|
|||
from peewee import Model
|
||||
|
||||
from afpy import database
|
||||
|
||||
|
||||
class BaseModel(Model):
|
||||
class Meta:
|
||||
database = database
|
|
@ -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
|
|
@ -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)
|
|
@ -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")
|
|
@ -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")
|
|
@ -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),
|
||||
)
|
|
@ -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 */
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
@ -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>
|
|
@ -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">×</span></button>
|
||||
<!-- <strong>Title</strong> --> {{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -0,0 +1,9 @@
|
|||
{% extends '_parts/base.jinja2' %}
|
||||
|
||||
{% block header %}
|
||||
<h1>{{ title }}</h1>
|
||||
{% endblock header %}
|
||||
|
||||
{% block main %}
|
||||
{{ html | safe }}
|
||||
{% endblock main %}
|
0
templates/already_published.rst → afpy/templates/rest/already_published.rst
Normal file → Executable file
0
templates/already_published.rst → afpy/templates/rest/already_published.rst
Normal file → Executable file
0
templates/already_trashed.rst → afpy/templates/rest/already_trashed.rst
Normal file → Executable file
0
templates/already_trashed.rst → afpy/templates/rest/already_trashed.rst
Normal file → Executable 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))
|
179
data_xml.py
179
data_xml.py
|
@ -1,179 +0,0 @@
|
|||
import email
|
||||
from pathlib import Path
|
||||
import time
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
POST_ACTUALITIES = 'actualites'
|
||||
POST_JOBS = 'emplois'
|
||||
POSTS = {POST_ACTUALITIES: "Actualités", POST_JOBS: "Offres d’emploi"}
|
||||
|
||||
STATE_WAITING = 'waiting'
|
||||
STATE_PUBLISHED = 'published'
|
||||
STATE_TRASHED = 'trashed'
|
||||
STATES = {
|
||||
STATE_WAITING: "En attente",
|
||||
STATE_PUBLISHED: "Publié",
|
||||
STATE_TRASHED: "À la corbeille"
|
||||
}
|
||||
|
||||
ACTION_PUBLISH = 'publish'
|
||||
ACTION_UNPUBLISH = 'unpublish'
|
||||
ACTION_REPUBLISH = 'republish'
|
||||
ACTION_TRASH = 'trash'
|
||||
ACTION_EDIT = 'edit'
|
||||
ACTION_DELETE_IMAGE = 'delete_image'
|
||||
ACTIONS = {
|
||||
ACTION_PUBLISH: "Publier",
|
||||
ACTION_UNPUBLISH: "Dépublier",
|
||||
ACTION_REPUBLISH: "Republier",
|
||||
ACTION_TRASH: "Supprimer",
|
||||
ACTION_EDIT: 'Editer',
|
||||
ACTION_DELETE_IMAGE: "Supprimer l'image"
|
||||
}
|
||||
|
||||
IMAGE = '_image'
|
||||
TIMESTAMP = '_timestamp'
|
||||
STATE = '_state'
|
||||
PATH = '_path'
|
||||
DIR = '_dir'
|
||||
|
||||
BASE_DIR = 'posts'
|
||||
BASE_FILE = 'post.xml'
|
||||
BASE_IMAGE = 'post.jpg'
|
||||
|
||||
|
||||
class DataException(Exception):
|
||||
def __init__(self, *args, http_code=None, **kwargs):
|
||||
self.http_code = http_code
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
root = Path(__file__).parent / BASE_DIR
|
||||
for category in POSTS:
|
||||
for state in STATES:
|
||||
(root / category / state).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_path(category, state, timestamp, *args, create_dir=False):
|
||||
path = root / category / state / timestamp
|
||||
if create_dir:
|
||||
path.mkdir(exist_ok=True)
|
||||
for arg in args:
|
||||
path /= arg
|
||||
return path
|
||||
|
||||
|
||||
def count_posts(category, state=STATE_PUBLISHED):
|
||||
return len(tuple((root / category / state).iterdir()))
|
||||
|
||||
|
||||
def get_posts(category, state=STATE_PUBLISHED, start=0, end=None):
|
||||
path = root / category / state
|
||||
timestamps = sorted(path.iterdir(), reverse=True)
|
||||
timestamps = timestamps[start:end] if end else timestamps[start:]
|
||||
for timestamp in timestamps:
|
||||
post = get_post(category, timestamp.name, state)
|
||||
if post:
|
||||
yield post
|
||||
|
||||
|
||||
def get_post(category, timestamp, states=None):
|
||||
states = (
|
||||
states
|
||||
if isinstance(states, (tuple, list))
|
||||
else [states]
|
||||
if isinstance(states, str)
|
||||
else STATES
|
||||
)
|
||||
for state in states:
|
||||
dir = root / category / state / timestamp
|
||||
path = dir / BASE_FILE
|
||||
if path.is_file():
|
||||
break
|
||||
else:
|
||||
return None
|
||||
tree = ElementTree.parse(path)
|
||||
post = {item.tag: (item.text or '').strip() for item in tree.iter()}
|
||||
|
||||
# Calculated fields
|
||||
image = post.get('image') or BASE_IMAGE
|
||||
if (dir / image).is_file():
|
||||
post[IMAGE] = '/'.join((category, state, timestamp, image))
|
||||
post[TIMESTAMP] = timestamp
|
||||
post[STATE] = state
|
||||
post[DIR] = dir
|
||||
post[PATH] = path
|
||||
return post
|
||||
|
||||
|
||||
def save_post(category, timestamp, admin, form, files):
|
||||
if timestamp is None:
|
||||
status = STATE_WAITING
|
||||
timestamp = str(int(time.time()))
|
||||
elif get_path(category, STATE_WAITING, timestamp, BASE_FILE).is_file():
|
||||
status = STATE_WAITING
|
||||
elif get_path(category, STATE_PUBLISHED, timestamp, BASE_FILE).is_file():
|
||||
status = STATE_PUBLISHED
|
||||
elif get_path(category, STATE_TRASHED, timestamp, BASE_FILE).is_file():
|
||||
status = STATE_TRASHED
|
||||
else:
|
||||
raise DataException(http_code=404)
|
||||
if status == STATE_TRASHED and not admin:
|
||||
raise DataException(http_code=401)
|
||||
|
||||
post = get_path(category, status, timestamp, BASE_FILE, create_dir=True)
|
||||
tree = ElementTree.Element('entry')
|
||||
|
||||
for key, value in form.items():
|
||||
if key.startswith('_'):
|
||||
continue
|
||||
element = ElementTree.SubElement(tree, key)
|
||||
element.text = value
|
||||
|
||||
if '_image_path' in form:
|
||||
image_path = root / form['_image_path']
|
||||
if ACTION_DELETE_IMAGE in form and image_path.exists():
|
||||
image_path.unlink()
|
||||
else:
|
||||
if 'image' in files and files['image'].filename:
|
||||
post_image = files['image']
|
||||
filename = secure_filename(post_image.filename)
|
||||
post_image.save(str(post.parent / filename))
|
||||
element = ElementTree.SubElement(tree, 'image')
|
||||
element.text = filename
|
||||
elif '_image_path' in form:
|
||||
element = ElementTree.SubElement(tree, 'image')
|
||||
element.text = image_path.name
|
||||
|
||||
element = ElementTree.SubElement(tree, STATE_PUBLISHED)
|
||||
element.text = email.utils.formatdate(
|
||||
int(timestamp) if timestamp else time.time()
|
||||
)
|
||||
ElementTree.ElementTree(tree).write(post)
|
||||
|
||||
if ACTION_TRASH in form and status == STATE_PUBLISHED:
|
||||
(root / category / STATE_PUBLISHED / timestamp).rename(
|
||||
root / category / STATE_TRASHED / timestamp
|
||||
)
|
||||
if not admin and ACTION_EDIT in form and status == STATE_PUBLISHED:
|
||||
(root / category / STATE_PUBLISHED / timestamp).rename(
|
||||
root / category / STATE_WAITING / timestamp
|
||||
)
|
||||
|
||||
if admin:
|
||||
if ACTION_PUBLISH in form and status == STATE_WAITING:
|
||||
(root / category / STATE_WAITING / timestamp).rename(
|
||||
root / category / STATE_PUBLISHED / timestamp
|
||||
)
|
||||
elif ACTION_UNPUBLISH in form and status == STATE_PUBLISHED:
|
||||
(root / category / STATE_PUBLISHED / timestamp).rename(
|
||||
root / category / STATE_WAITING / timestamp
|
||||
)
|
||||
elif ACTION_REPUBLISH in form and status == STATE_TRASHED:
|
||||
(root / category / STATE_TRASHED / timestamp).rename(
|
||||
root / category / STATE_PUBLISHED / timestamp
|
||||
)
|
||||
|
||||
return get_post(category, timestamp)
|
BIN
posts.tar.bz2
BIN
posts.tar.bz2
Binary file not shown.
|
@ -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
|
||||
|
||||
)/
|
||||
'''
|
|
@ -0,0 +1,5 @@
|
|||
pre-commit
|
||||
pytest
|
||||
pytest-cov
|
||||
pytest-flake8
|
||||
pytest-black
|
|
@ -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
|
|
@ -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)
|
283
sass/style.sass
283
sass/style.sass
|
@ -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%
|
34
setup.py
34
setup.py
|
@ -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]'}
|
||||
)
|
|
@ -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 */
|
|
@ -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 (8pt)",2:"2 (10pt)",3:"3 (12pt)",4:"4 (14pt)",5:"5 (18pt)",6:"6 (24pt)"},init:function(){this.setDisplay("Font 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 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 6",h5:"Heading 5",h4:"Heading 4",h3:"Heading 3",h2:"Heading 2",h1:"Heading 1"},init:function(){this.setDisplay("Font 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 |
|
@ -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 %}
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -1,9 +0,0 @@
|
|||
{% extends '_layout.jinja2' %}
|
||||
|
||||
{% block header %}
|
||||
<h1>{{ title }}</h1>
|
||||
{% endblock header %}
|
||||
|
||||
{% block main %}
|
||||
{{ html | safe }}
|
||||
{% endblock main %}
|
20
tests.py
20
tests.py
|
@ -1,41 +1,41 @@
|
|||
import pytest
|
||||
|
||||
from afpy import app
|
||||
from afpy import application
|
||||
|
||||
|
||||
def test_home():
|
||||
response = app.test_client().get("/")
|
||||
response = application.test_client().get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["", "communaute"])
|
||||
def test_html(name):
|
||||
response = app.test_client().get(f"/{name}")
|
||||
response = application.test_client().get(f"/{name}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["charte", "a-propos"])
|
||||
def test_rest(name):
|
||||
response = app.test_client().get(f"/docs/{name}")
|
||||
response = application.test_client().get(f"/docs/{name}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_planet():
|
||||
response = app.test_client().get("/planet/")
|
||||
response = application.test_client().get("/planet/")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_404():
|
||||
response = app.test_client().get("/unknown")
|
||||
response = application.test_client().get("/unknown")
|
||||
assert response.status_code == 404
|
||||
response = app.test_client().get("/docs/unknown")
|
||||
response = application.test_client().get("/docs/unknown")
|
||||
assert response.status_code == 404
|
||||
response = app.test_client().get("/feed/unknown")
|
||||
response = application.test_client().get("/feed/unknown")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_read_posts():
|
||||
response = app.test_client().get("/posts/actualites")
|
||||
response = application.test_client().get("/actualites/page/1")
|
||||
assert response.status_code == 200
|
||||
response = app.test_client().get("/posts/emplois")
|
||||
response = application.test_client().get("/emplois/page/1")
|
||||
assert response.status_code == 200
|
||||
|
|
|
@ -0,0 +1,139 @@
|
|||
# coding: utf-8
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from dateutil.parser import parse
|
||||
from html2text import html2text
|
||||
|
||||
from afpy.models.AdminUser import AdminUser
|
||||
from afpy.models.JobPost import JobPost
|
||||
from afpy.models.NewsEntry import NewsEntry
|
||||
from afpy.models.Slug import Slug
|
||||
|
||||
PAGINATION = 12
|
||||
|
||||
CATEGORY_ACTUALITIES = "actualites"
|
||||
CATEGORY_JOBS = "emplois"
|
||||
|
||||
CATEGORIES = {CATEGORY_ACTUALITIES: "Actualités", CATEGORY_JOBS: "Offres d’emploi"}
|
||||
|
||||
STATE_WAITING = "waiting"
|
||||
STATE_PUBLISHED = "published"
|
||||
STATE_TRASHED = "trashed"
|
||||
STATES = {STATE_WAITING: "En attente", STATE_PUBLISHED: "Publié", STATE_TRASHED: "Supprimé"}
|
||||
|
||||
FIELD_IMAGE = "_image"
|
||||
FIELD_TIMESTAMP = "_timestamp"
|
||||
FIELD_STATE = "_state"
|
||||
FIELD_PATH = "_path"
|
||||
FIELD_DIR = "_dir"
|
||||
|
||||
BASE_DIR = "posts"
|
||||
BASE_FILE = "post.xml"
|
||||
BASE_IMAGE = "post.jpg"
|
||||
|
||||
ROOT_DIR = Path(__file__).parent
|
||||
POSTS_DIR = ROOT_DIR / BASE_DIR
|
||||
IMAGE_DIR = ROOT_DIR / "images"
|
||||
IMAGE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def get_posts(category, state=STATE_PUBLISHED, page=None, end=None):
|
||||
start = 0
|
||||
if page and not end:
|
||||
end = page * PAGINATION
|
||||
start = end - PAGINATION
|
||||
path = POSTS_DIR / category / state
|
||||
timestamps = sorted(path.iterdir(), reverse=True)
|
||||
timestamps = timestamps[start:end] if end else timestamps[start:]
|
||||
for timestamp in timestamps:
|
||||
post = get_post(category, timestamp.name, state)
|
||||
if post:
|
||||
yield post
|
||||
|
||||
|
||||
def get_post(category, timestamp, states=None):
|
||||
states = tuple(
|
||||
states if isinstance(states, (tuple, list)) else [states] if isinstance(states, str) else STATES.keys()
|
||||
)
|
||||
for state in states:
|
||||
dir = POSTS_DIR / category / state / timestamp
|
||||
path = dir / BASE_FILE
|
||||
if path.is_file():
|
||||
break
|
||||
else:
|
||||
return None
|
||||
tree = ElementTree.parse(path)
|
||||
post = {item.tag: (item.text or "").strip() for item in tree.iter()}
|
||||
|
||||
# Calculated fields
|
||||
image = post.get("image") or post.get("old_image") or BASE_IMAGE
|
||||
if (dir / image).is_file():
|
||||
post[FIELD_IMAGE] = "/".join((category, state, timestamp, image))
|
||||
post[FIELD_TIMESTAMP] = timestamp
|
||||
post[FIELD_STATE] = state
|
||||
post[FIELD_DIR] = dir
|
||||
post[FIELD_PATH] = path
|
||||
return post
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
admin_1 = AdminUser.get_by_id(1)
|
||||
for category in CATEGORIES:
|
||||
for state in STATES:
|
||||
for post in get_posts(category, state):
|
||||
timestamp = post.get(FIELD_TIMESTAMP)
|
||||
if post.get(FIELD_IMAGE):
|
||||
image = POSTS_DIR / post.get(FIELD_IMAGE)
|
||||
name, ext = os.path.splitext(post.get(FIELD_IMAGE))
|
||||
post["image"] = f"{category}.{timestamp}{ext}"
|
||||
shutil.copy(str(image), str(IMAGE_DIR / post["image"]))
|
||||
if category == "actualites":
|
||||
new_post = NewsEntry.create(
|
||||
title=post.get("title", "(untitled)"),
|
||||
summary=post.get("summary"),
|
||||
content=html2text(post.get("content", "")),
|
||||
author="Admin",
|
||||
author_email=post.get("email"),
|
||||
image_path=post.get("image"),
|
||||
dt_published=parse(post.get("published")).replace(tzinfo=None)
|
||||
if state == "published"
|
||||
else None,
|
||||
dt_submitted=parse(post.get("published")).replace(tzinfo=None),
|
||||
dt_updated=parse(post.get("published")).replace(tzinfo=None),
|
||||
state=state,
|
||||
approved_by=admin_1 if state == "published" or state == "rejected" else None,
|
||||
)
|
||||
Slug.create(url=f"/posts/actualites/{post.get(FIELD_TIMESTAMP)}", newsentry=new_post)
|
||||
post_id = post.get("id")
|
||||
if post_id:
|
||||
Slug.create(url=post_id.split("afpy.org")[-1], newsentry=new_post)
|
||||
else:
|
||||
email = post.get("email", "")
|
||||
phone = post.get("phone", "")
|
||||
if not email and not phone:
|
||||
phone = "(no phone)"
|
||||
new_job = JobPost.create(
|
||||
title=post.get("title", "(untitled)"),
|
||||
summary=post.get("summary"),
|
||||
content=html2text(post.get("content", "")),
|
||||
company=post.get("company", ""),
|
||||
email=email,
|
||||
phone=phone,
|
||||
location=post.get("address", ""),
|
||||
contact_info=post.get("contact", ""),
|
||||
dt_published=parse(post.get("published")).replace(tzinfo=None)
|
||||
if state == "published"
|
||||
else None,
|
||||
dt_submitted=parse(post.get("published")).replace(tzinfo=None),
|
||||
dt_updated=parse(post.get("published")).replace(tzinfo=None),
|
||||
state=state,
|
||||
approved_by=admin_1 if state == "published" or state == "rejected" else None,
|
||||
image_path=post.get("image"),
|
||||
)
|
||||
Slug.create(url=f"/posts/emplois/{post.get(FIELD_TIMESTAMP)}", jobpost=new_job)
|
||||
post_id = post.get("id")
|
||||
if post_id:
|
||||
Slug.create(url=post_id.split("afpy.org")[-1], jobpost=new_job)
|
Loading…
Reference in New Issue