From 479d8eecc02fae5e70ace72cc4efe87326edf482 Mon Sep 17 00:00:00 2001
From: Mattia Succurro
Date: Wed, 6 Oct 2021 22:48:14 +0200
Subject: [PATCH] Adding some extensions and appearance improvements.
---
app.py | 32 ++++-
app_sync.py | 40 ++++++
extensions/circles.py | 21 ++-
extensions/contactnova.py | 214 ++++++++++++++++++++++++++++++
static/style.css | 26 ++++
strings.csv | 4 +
templates/circles/add.html | 1 +
templates/circles/list.html | 11 +-
templates/circles/stats.html | 9 +-
templates/contactnova/list.html | 59 ++++++++
templates/contactnova/new.html | 72 ++++++++++
templates/contactnova/single.html | 24 ++++
templates/view.html | 5 +-
13 files changed, 506 insertions(+), 12 deletions(-)
create mode 100644 extensions/contactnova.py
create mode 100644 templates/contactnova/list.html
create mode 100644 templates/contactnova/new.html
create mode 100644 templates/contactnova/single.html
diff --git a/app.py b/app.py
index 9615406..044d704 100644
--- a/app.py
+++ b/app.py
@@ -12,7 +12,7 @@ Extensions are supported (?), kept in extensions/ folder.
'''
from flask import (
- Flask, abort, flash, g, jsonify, make_response, redirect, request,
+ Flask, Markup, abort, flash, g, jsonify, make_response, redirect, request,
render_template, send_from_directory)
from werkzeug.routing import BaseConverter
from peewee import *
@@ -33,7 +33,7 @@ try:
except Exception:
markdown_strikethrough = None
-__version__ = '0.3.0'
+__version__ = '0.4-dev'
#### CONSTANTS ####
@@ -205,6 +205,20 @@ class PageRevision(BaseModel):
return self.textref.get_content()
def html(self):
return md(self.text)
+ def human_pub_date(self):
+ delta = datetime.datetime.now() - self.pub_date
+ T = partial(get_string, g.lang)
+ if delta < datetime.timedelta(seconds=60):
+ return T('just-now')
+ elif delta < datetime.timedelta(seconds=3600):
+ return T('n-minutes-ago').format(delta.seconds // 60)
+
+ elif delta < datetime.timedelta(days=1):
+ return T('n-hours-ago').format(delta.seconds // 3600)
+ elif delta < datetime.timedelta(days=15):
+ return T('n-days-ago').format(delta.days)
+ else:
+ return self.pub_date.strftime('%B %-d, %Y')
class PageTag(BaseModel):
page = FK(Page, backref='tags', index=True)
@@ -496,13 +510,17 @@ forbidden_urls = [
'create', 'edit', 'p', 'ajax', 'history', 'manage', 'static', 'media',
'accounts', 'tags', 'init-config', 'upload', 'upload-info', 'about',
'stats', 'terms', 'privacy', 'easter', 'search', 'help', 'circles',
- 'protect',
+ 'protect', 'kt'
]
+
app = Flask(__name__)
app.secret_key = 'qrdldCcvamtdcnidmtasegasdsedrdqvtautar'
app.url_map.converters['slug'] = SlugConverter
+
+
+
#### ROUTES ####
@app.before_request
@@ -525,6 +543,12 @@ def _inject_variables():
'strong': lambda x:Markup('{0}').format(x),
}
+@app.template_filter()
+def linebreaks(text):
+ text = html.escape(text)
+ text = text.replace("\n\n", '
').replace('\n', '
')
+ return Markup(text)
+
@app.route('/')
def homepage():
page_limit = _getconf("appearance","items_per_page",20,cast=int)
@@ -848,7 +872,7 @@ def easter_y(y=None):
#### EXTENSIONS ####
-active_extensions = ['circles']
+active_extensions = ['contactnova']
for ext in active_extensions:
try:
diff --git a/app_sync.py b/app_sync.py
index 25ba22d..1c55434 100644
--- a/app_sync.py
+++ b/app_sync.py
@@ -68,6 +68,41 @@ def fetch_updated_ids(baseurl):
raise RuntimeError("sync unavailable")
return r.json()["ids"]
+def update_contacts(baseurl):
+ from extensions.contactnova import Contact
+ try:
+ with open(_getconf("config", "database_dir") + "/latest_sync") as f:
+ last_sync = float(f.read().rstrip("\n"))
+ except (OSError, ValueError):
+ last_sync = 946681200.0 # Jan 1, 2000
+ r = requests.get(baseurl + "/kt/_jsoninfo/{ts}".format(ts=last_sync))
+ if r.status_code >= 400:
+ raise RuntimeError("sync unavailable")
+ # update contacts
+ updated = 0
+ for pinfo in r.json()['data']:
+ p = Contact.get_or_none(Contact.code == pinfo['code'])
+ if p is None:
+ p = Contact.create(
+ code = pinfo['code'],
+ display_name = pinfo['display_name'],
+ issues = pinfo['issues'],
+ status = pinfo['status'],
+ description = pinfo['description'],
+ due = datetime.date.fromtimestamp(pinfo['due'])
+ )
+ else:
+ p.display_name = pinfo['display_name']
+ p.issues = pinfo['issues']
+ p.status = pinfo['status']
+ p.description = pinfo['description']
+ p.due = datetime.date.fromtimestamp(pinfo['due'])
+ p.touched = datetime.datetime.now()
+ p.save()
+ updated += 1
+ print('\x1b[32m{0} contacts updated :)\x1b[0m'.format(updated))
+
+
def update_page(p, pageinfo):
p.touched = datetime.datetime.fromtimestamp(pageinfo["touched"])
p.url = pageinfo["url"]
@@ -123,6 +158,11 @@ def main():
if pageinfo["touched"] > p.touched.timestamp():
update_page(p, pageinfo)
passed += 1
+ try:
+ if _getconf("sync", "contacts", None) is not None:
+ update_contacts(baseurl)
+ except Exception as e:
+ print("\x1b[33mContacts not updated - {e} :(\x1b[0m".format(e=e))
with open(DATABASE_DIR + "/last_sync", "w") as fw:
fw.write(str(time.time()))
if passed > 0 and failed == 0:
diff --git a/extensions/circles.py b/extensions/circles.py
index 9ddbc05..b3b813e 100644
--- a/extensions/circles.py
+++ b/extensions/circles.py
@@ -170,8 +170,8 @@ def edit_detail(id):
p.type = request.form["type"]
p.area = request.form['area']
p.save()
- return redirect("/circles")
- return render_template("circles/add.html", pl=p)
+ return redirect(request.form['returnto'])
+ return render_template("circles/add.html", pl=p, returnto=request.headers.get('Referer', '/circles'))
@bp.route('/csv', methods=['GET', 'POST'])
def add_csv():
@@ -207,6 +207,16 @@ def statuslist(typ):
q = Person.select().where(Person.status == typ).order_by(Person.touched.desc())
return paginate_list(['Orange', 'Yellow', 'Green', ..., 'Red'][typ], q)
+@bp.route('/area-')
+def arealist(a):
+ q = Person.select().where(Person.area == a).order_by(Person.status.desc(), Person.touched.desc())
+ return paginate_list('Area {}'.format(a), q)
+
+@bp.route('/no-area')
+def noarealist():
+ q = Person.select().where(Person.area == 0).order_by(Person.touched.desc())
+ return paginate_list('Unassigned area', q)
+
@bp.route("/stats")
def stats():
bq = Person.select()
@@ -222,5 +232,10 @@ def stats():
'Orange': bq.where(Person.status == 0).count(),
'Yellow': bq.where(Person.status == 1).count(),
'Green': bq.where(Person.status == 2).count()
- }
+ },
+ area_count={
+ k: bq.where(Person.area == k).count()
+ for k in range(1, 13)
+ },
+ no_area_count=bq.where(Person.area == None).count()
)
diff --git a/extensions/contactnova.py b/extensions/contactnova.py
new file mode 100644
index 0000000..579a421
--- /dev/null
+++ b/extensions/contactnova.py
@@ -0,0 +1,214 @@
+# (c) 2021 Sakuragasaki46
+# See LICENSE for copying info.
+
+'''
+Contact Nova extension for Salvi.
+'''
+
+from peewee import *
+import datetime
+from app import _getconf
+from flask import Blueprint, request, redirect, render_template, jsonify, abort
+from werkzeug.routing import BaseConverter
+import csv
+import io
+import re
+import itertools
+
+#### HELPERS ####
+
+class RegExpField(CharField):
+ def __init__(self, regex, max_length=255, *args, **kw):
+ super().__init__(max_length, *args, **kw)
+ self.regex = regex
+ def db_value(self, value):
+ value = value.strip()
+ # XXX %: bug fix for LIKE, but it’s not something regular!
+ if not '%' in value and not re.fullmatch(self.regex, value):
+ raise IntegrityError("regexp mismatch: {!r}".format(self.regex))
+ return value
+
+def now7days():
+ return datetime.datetime.now() + datetime.timedelta(days=7)
+
+#### DATABASE SCHEMA ####
+
+database = SqliteDatabase(_getconf("config", "database_dir") + '/contactnova.sqlite')
+
+class BaseModel(Model):
+ class Meta:
+ database = database
+
+ST_UNKNOWN = 0
+ST_OK = 1
+ST_ISSUES = 2
+
+class Contact(BaseModel):
+ code = RegExpField(r'[A-Z]\.\d+', 30, unique=True)
+ display_name = RegExpField('[A-Za-z0-9_. ]+', 50, index=True)
+ status = IntegerField(default=ST_UNKNOWN, index=True)
+ issues = CharField(500, default='')
+ description = CharField(5000, default='')
+ due = DateField(index=True, default=now7days)
+ touched = DateTimeField(index=True, default=datetime.datetime.now)
+ def status_str(self):
+ return {
+ 1: "task_alt",
+ 2: "error_outline",
+ }.get(self.status, "")
+ def __repr__(self):
+ return '<{0.__class__.__name__}: {0.code}>'.format(self)
+ @classmethod
+ def next_code(cls, letter):
+ try:
+ last_code = Contact.select().where(Contact.code ** (letter + '%')).order_by(Contact.id.desc()).first().code.split('.')
+ except (Contact.DoesNotExist, AttributeError):
+ last_code = letter, '0'
+ code = letter + '.' + str(int(last_code[1]) + 1)
+ return code
+
+def init_db():
+ database.create_tables([Contact])
+
+def create_cli():
+ code = Contact.next_code(input('Code letter:'))
+
+ return Contact.create(
+ code=code,
+ display_name=input('Display name:'),
+ due=datetime.datetime.fromisoformat(input('Due date (ISO):')),
+ description=input('Description (optional):')
+ )
+
+# Helper for command line.
+class _CCClass:
+ def __init__(self, cb=lambda x:x):
+ self.callback = cb
+ def __neg__(self):
+ return create_cli()
+ def __getattr__(self, value):
+ code = value[0] + '.' + value[1:]
+ try:
+ return self.callback(Contact.get(Contact.code == code))
+ except Contact.DoesNotExist:
+ raise AttributeError(value) from None
+ def ok(self):
+ def cb(a):
+ a.status = 1
+ a.save()
+ return a
+ return self.__class__(cb)
+CC = _CCClass()
+del _CCClass
+
+#### ROUTE HELPERS ####
+
+class ContactCodeConverter(BaseConverter):
+ regex = r'[A-Z]\.\d+'
+
+class ContactMockConverter(BaseConverter):
+ regex = r'[A-Z]'
+
+def _register_converters(state):
+ state.app.url_map.converters['contactcode'] = ContactCodeConverter
+ state.app.url_map.converters['singleletter'] = ContactMockConverter
+
+# Helper for pagination.
+def paginate_list(cat, q):
+ pageno = int(request.args.get('page', 1))
+ return render_template(
+ "contactnova/list.html",
+ cat=cat,
+ count=q.count(),
+ people=q.offset(50 * (pageno - 1)).limit(50),
+ pageno=pageno
+ )
+
+bp = Blueprint('contactnova', __name__,
+ url_prefix='/kt')
+bp.record_once(_register_converters)
+
+@bp.route('/init-config')
+def _init_config():
+ init_db()
+ return redirect('/kt')
+
+@bp.route('/')
+def homepage():
+ q = Contact.select().where(Contact.due > datetime.datetime.now()).order_by(Contact.due.desc())
+ return paginate_list('All', q)
+
+@bp.route('/expired')
+def expired():
+ q = Contact.select().where(Contact.due <= datetime.datetime.now()).order_by(Contact.due)
+ return paginate_list('Expired', q)
+
+@bp.route('/ok')
+def sanecontacts():
+ q = Contact.select().where((Contact.due > datetime.datetime.now()) &
+ (Contact.status == ST_OK)).order_by(Contact.due)
+ return paginate_list('Sane', q)
+
+@bp.route('/')
+def singleletter(l):
+ q = Contact.select().where(Contact.code ** (l + '%')).order_by(Contact.id)
+ return paginate_list('Series {}'.format(l), q)
+
+@bp.route('/')
+def singlecontact(code):
+ try:
+ p = Contact.get(Contact.code == code)
+ except IntegrityError:
+ abort(404)
+ return render_template('contactnova/single.html', p=p)
+
+@bp.route('/_newcode/')
+def newcode(l):
+ return Contact.next_code(l), {"Content-Type": "text/plain"}
+
+@bp.route('/new', methods=['GET', 'POST'])
+def newcontact():
+ if request.method == 'POST':
+ Contact.create(
+ code = Contact.next_code(request.form['letter']),
+ display_name = request.form['display_name'],
+ status = request.form['status'],
+ issues = request.form['issues'],
+ description = request.form['description'],
+ due = datetime.date.fromisoformat(request.form['due'])
+ )
+ return redirect(request.form.get('returnto','/kt/'+request.form['letter']))
+ return render_template('contactnova/new.html', pl_date = now7days())
+
+@bp.route('/edit/', methods=['GET', 'POST'])
+def editcontact(code):
+ pl = Contact.get(Contact.code == code)
+ if request.method == 'POST':
+ pl.display_name = request.form['display_name']
+ pl.issues = request.form['issues']
+ pl.status = request.form['status']
+ pl.description = request.form['description']
+ pl.due = datetime.date.fromisoformat(request.form['due'])
+ pl.touched = datetime.datetime.now()
+ pl.save()
+ return redirect(request.form.get('returnto','/kt/'+pl.code[0]))
+ return render_template('contactnova/new.html', pl = pl)
+
+@bp.route('/_jsoninfo/')
+def contact_jsoninfo(ts):
+ tse = str(datetime.datetime.fromtimestamp(ts).isoformat(" "))
+ ps = Contact.select().where(Contact.touched >= tse)
+ return jsonify({
+ "ids": [i.code for i in ps],
+ "data": [
+ {
+ 'code': i.code,
+ 'display_name': i.display_name,
+ 'due': datetime.datetime.fromisoformat(i.due.isoformat()).timestamp(),
+ 'issues': i.issues,
+ 'description': i.description,
+ 'status': i.status
+ } for i in ps
+ ],
+ "status": "ok"
+ })
diff --git a/static/style.css b/static/style.css
index 19d3ae5..4dcdcf4 100644
--- a/static/style.css
+++ b/static/style.css
@@ -41,6 +41,8 @@ input[type="submit"],input[type="reset"],input[type="button"],button{font-family
.page-tags .tag-count{color:#3c3;font-size:smaller;font-weight:600}
.search-wrapper {display:flex;width:90%;margin:auto}
.search-wrapper > input {flex:1}
+
+/* Circles extension */
.circles-red{color: #e14}
.circles-orange{color: #f72}
.circles-green{color: #6e4}
@@ -56,6 +58,14 @@ input[type="submit"],input[type="reset"],input[type="button"],button{font-family
.circles-add-form > div{display:table-row}
.circles-add-form > div > *{display:table-cell}
.circles-add-form > div > label{text-align:right}
+.circles-li-code {font-size: 120%}
+
+/* ContactNova extension */
+.contactnova-issues {padding-left: 12px; border-left: 4px solid #fc6}
+.contactnova-col-code {font-size: 1.25em; font-weight: bold}
+.contactnova-status_1 .material-icons {color: #6e4}
+.contactnova-status_2 .material-icons {color: #fc6}
+.contactnova-list td {vertical-align: middle}
/* floating elements */
@@ -132,3 +142,19 @@ body.dark, .dark input, .dark textarea{background-color: #1f1f1f; color: white}
a.dark-theme-toggle-off{display: none}
.dark a.dark-theme-toggle-off{display: inline}
.dark a.dark-theme-toggle-on{display: none}
+
+/* ?????? */
+.wrap_responsive_cells {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ /* This is better for small screens, once min() is better supported */
+ /* grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); */
+ gap: 1rem;
+}
+.wrap_responsive_cells_narrow {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ /* This is better for small screens, once min() is better supported */
+ /* grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); */
+ gap: 2px;
+}
diff --git a/strings.csv b/strings.csv
index f06345a..af52858 100644
--- a/strings.csv
+++ b/strings.csv
@@ -23,3 +23,7 @@ search,Search,Cerca
year,Year,Anno
calculate,Calculate,Calcola
show-all,Show all,Mostra tutto
+just-now,just now,poco fa
+n-minutes-ago,{0} minutes ago,{0} minuti fa
+n-hours-ago,{0} hours ago,{0} ore fa
+n-days-ago,{0} days ago,{0} giorni fa
diff --git a/templates/circles/add.html b/templates/circles/add.html
index de1c79c..eb2a937 100644
--- a/templates/circles/add.html
+++ b/templates/circles/add.html
@@ -5,6 +5,7 @@
{% block content %}