Add circles extension
This commit is contained in:
parent
a712303ccd
commit
ec720743b3
8 changed files with 321 additions and 5 deletions
2
app.py
2
app.py
|
|
@ -848,7 +848,7 @@ def easter_y(y=None):
|
||||||
|
|
||||||
#### EXTENSIONS ####
|
#### EXTENSIONS ####
|
||||||
|
|
||||||
active_extensions = []
|
active_extensions = ['circles']
|
||||||
|
|
||||||
for ext in active_extensions:
|
for ext in active_extensions:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
0
extensions/__init__.py
Normal file
0
extensions/__init__.py
Normal file
|
|
@ -6,7 +6,13 @@ Circles (people) extension for Salvi.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
from peewee import *
|
from peewee import *
|
||||||
from ..app import _getconf
|
import datetime
|
||||||
|
from app import _getconf
|
||||||
|
from flask import Blueprint, request, redirect, render_template
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import itertools
|
||||||
|
|
||||||
#### HELPERS ####
|
#### HELPERS ####
|
||||||
|
|
||||||
|
|
@ -25,7 +31,7 @@ def _getmbt(s):
|
||||||
|
|
||||||
def _putmbt(s):
|
def _putmbt(s):
|
||||||
if s & 16 == 0:
|
if s & 16 == 0:
|
||||||
return "1x38b"
|
return "1x38B"
|
||||||
return ''.join((
|
return ''.join((
|
||||||
'IE'[(s & 8) >> 3],
|
'IE'[(s & 8) >> 3],
|
||||||
'NS'[(s & 4) >> 2],
|
'NS'[(s & 4) >> 2],
|
||||||
|
|
@ -34,6 +40,7 @@ def _putmbt(s):
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#### DATABASE SCHEMA ####
|
#### DATABASE SCHEMA ####
|
||||||
|
|
||||||
database = SqliteDatabase(_getconf("config", "database_dir") + '/circles.sqlite')
|
database = SqliteDatabase(_getconf("config", "database_dir") + '/circles.sqlite')
|
||||||
|
|
@ -46,6 +53,8 @@ class MbTypeField(Field):
|
||||||
field_type = 'integer'
|
field_type = 'integer'
|
||||||
|
|
||||||
def db_value(self, value):
|
def db_value(self, value):
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
return _getmbt(value)
|
return _getmbt(value)
|
||||||
def python_value(self, value):
|
def python_value(self, value):
|
||||||
return _putmbt(value)
|
return _putmbt(value)
|
||||||
|
|
@ -63,6 +72,8 @@ class Person(BaseModel):
|
||||||
circle = IntegerField(default=7, index=True)
|
circle = IntegerField(default=7, index=True)
|
||||||
status = IntegerField(default=ST_ORANGE, index=True)
|
status = IntegerField(default=ST_ORANGE, index=True)
|
||||||
type = MbTypeField(default=0, index=True)
|
type = MbTypeField(default=0, index=True)
|
||||||
|
area = IntegerField(default=0, index=True)
|
||||||
|
touched = DateTimeField(default=datetime.datetime.now)
|
||||||
class Meta:
|
class Meta:
|
||||||
indexes = (
|
indexes = (
|
||||||
(('last_name', 'first_name'), False),
|
(('last_name', 'first_name'), False),
|
||||||
|
|
@ -71,16 +82,145 @@ class Person(BaseModel):
|
||||||
def init_db():
|
def init_db():
|
||||||
database.create_tables([Person])
|
database.create_tables([Person])
|
||||||
|
|
||||||
|
def add_from_csv(s):
|
||||||
|
f = io.StringIO()
|
||||||
|
f.write(s)
|
||||||
|
f.seek(0)
|
||||||
|
rd = csv.reader(f)
|
||||||
|
for line in rd:
|
||||||
|
code = line[0]
|
||||||
|
if not code.isdigit():
|
||||||
|
continue
|
||||||
|
names = line[1:4]
|
||||||
|
while len(names) < 3:
|
||||||
|
names.append('')
|
||||||
|
if not names[2]:
|
||||||
|
names[2] = names[0] + " " + names[1]
|
||||||
|
type_ = line[4] if len(line) > 4 else 0
|
||||||
|
try:
|
||||||
|
p = Person[code]
|
||||||
|
except Person.DoesNotExist:
|
||||||
|
p = Person.create(
|
||||||
|
code = code,
|
||||||
|
display_name = names[2],
|
||||||
|
first_name = names[0],
|
||||||
|
last_name = names[1],
|
||||||
|
type = type_
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
p.touched = datetime.datetime.now()
|
||||||
|
p.first_name, p.last_name, p.display_name = names
|
||||||
|
p.type = type_ or p.type
|
||||||
|
p.save()
|
||||||
|
|
||||||
#### ROUTING ####
|
#### ROUTING ####
|
||||||
|
|
||||||
|
class MbTypeConverter(BaseConverter):
|
||||||
|
regex = '[IE][NS][TF][JP]|[ie][ns][tf][jp]'
|
||||||
|
def to_python(self, value):
|
||||||
|
return value.upper()
|
||||||
|
def to_url(self, value):
|
||||||
|
return value.lower()
|
||||||
|
|
||||||
|
class StatusColorConverter(BaseConverter):
|
||||||
|
regex = 'red|yellow|green|orange'
|
||||||
|
def to_python(self, value):
|
||||||
|
if value == 'red':
|
||||||
|
return -1
|
||||||
|
return ['orange', 'yellow', 'green'].index(value)
|
||||||
|
def to_url(self, value):
|
||||||
|
return ['orange', 'yellow', 'green', ..., 'red'][value]
|
||||||
|
|
||||||
|
def _register_converters(state):
|
||||||
|
state.app.url_map.converters['mbtype'] = MbTypeConverter
|
||||||
|
state.app.url_map.converters['statuscolor'] = StatusColorConverter
|
||||||
|
|
||||||
bp = Blueprint('circles', __name__,
|
bp = Blueprint('circles', __name__,
|
||||||
url_prefix='/circles')
|
url_prefix='/circles')
|
||||||
|
bp.record_once(_register_converters)
|
||||||
|
|
||||||
@bp.route('/init-config')
|
@bp.route('/init-config')
|
||||||
def _init_config():
|
def _init_config():
|
||||||
init_db()
|
init_db()
|
||||||
return redirect('/circles')
|
return redirect('/circles')
|
||||||
|
|
||||||
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
|
def add_new():
|
||||||
|
if request.method == 'POST':
|
||||||
|
p = Person.create(
|
||||||
|
code = request.form["code"],
|
||||||
|
display_name = request.form["display_name"],
|
||||||
|
first_name = request.form["first_name"],
|
||||||
|
last_name = request.form["last_name"],
|
||||||
|
type = request.form["type"],
|
||||||
|
status = int(request.form.get('status', 0))
|
||||||
|
)
|
||||||
|
return redirect("/circles")
|
||||||
|
return render_template("circles/add.html")
|
||||||
|
|
||||||
|
@bp.route('/edit/<int:id>', methods=['GET', 'POST'])
|
||||||
|
def edit_detail(id):
|
||||||
|
p = Person[id]
|
||||||
|
if request.method == 'POST':
|
||||||
|
p.touched = datetime.datetime.now()
|
||||||
|
p.first_name = request.form['first_name']
|
||||||
|
p.last_name = request.form['last_name']
|
||||||
|
p.display_name = request.form['display_name']
|
||||||
|
p.status = int(request.form.get('status', 0))
|
||||||
|
p.type = request.form["type"]
|
||||||
|
p.area = request.form['area']
|
||||||
|
p.save()
|
||||||
|
return redirect("/circles")
|
||||||
|
return render_template("circles/add.html", pl=p)
|
||||||
|
|
||||||
|
@bp.route('/csv', methods=['GET', 'POST'])
|
||||||
|
def add_csv():
|
||||||
|
if request.method == 'POST' and request.form.get('consent') == 'y':
|
||||||
|
add_from_csv(request.form['text'])
|
||||||
|
return redirect('/circles')
|
||||||
|
return render_template("circles/csv.html")
|
||||||
|
|
||||||
|
# Helper for pagination.
|
||||||
|
def paginate_list(cat, q):
|
||||||
|
pageno = int(request.args.get('page', 1))
|
||||||
|
return render_template(
|
||||||
|
"circles/list.html",
|
||||||
|
cat=cat,
|
||||||
|
count=q.count(),
|
||||||
|
people=q.offset(50 * (pageno - 1)).limit(50),
|
||||||
|
pageno=pageno
|
||||||
|
)
|
||||||
|
|
||||||
@bp.route('/')
|
@bp.route('/')
|
||||||
def homepage():
|
def homepage():
|
||||||
return render_template("base.html")
|
q = Person.select().order_by(Person.touched.desc())
|
||||||
|
return paginate_list('all', q)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<mbtype:typ>')
|
||||||
|
def typelist(typ):
|
||||||
|
q = Person.select().where(Person.type == typ).order_by(Person.touched.desc())
|
||||||
|
return paginate_list(typ, q)
|
||||||
|
|
||||||
|
@bp.route('/<statuscolor:typ>')
|
||||||
|
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("/stats")
|
||||||
|
def stats():
|
||||||
|
bq = Person.select()
|
||||||
|
return render_template(
|
||||||
|
"circles/stats.html",
|
||||||
|
count=bq.count(),
|
||||||
|
typed_count={
|
||||||
|
''.join(x) : bq.where(Person.type == ''.join(x)).count()
|
||||||
|
for x in itertools.product('IE', 'NS', 'TF', 'JP')
|
||||||
|
},
|
||||||
|
status_count={
|
||||||
|
'Red': bq.where(Person.status == -1).count(),
|
||||||
|
'Orange': bq.where(Person.status == 0).count(),
|
||||||
|
'Yellow': bq.where(Person.status == 1).count(),
|
||||||
|
'Green': bq.where(Person.status == 2).count()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,21 @@ input[type="submit"],input[type="reset"],input[type="button"],button{font-family
|
||||||
.page-tags .tag-count{color:#3c3;font-size:smaller;font-weight:600}
|
.page-tags .tag-count{color:#3c3;font-size:smaller;font-weight:600}
|
||||||
.search-wrapper {display:flex;width:90%;margin:auto}
|
.search-wrapper {display:flex;width:90%;margin:auto}
|
||||||
.search-wrapper > input {flex:1}
|
.search-wrapper > input {flex:1}
|
||||||
|
.circles-red{color: #e14}
|
||||||
|
.circles-orange{color: #f72}
|
||||||
|
.circles-green{color: #6e4}
|
||||||
|
.circles-yellow{color: #fc6}
|
||||||
|
.circles-list{list-style: none}
|
||||||
|
.circles-list > li{margin: .5em 0;}
|
||||||
|
.circles-list > li::before{font-size: 24px;transform:translatey(50%);font-weight:bold;margin-right:8px}
|
||||||
|
.circles-list > li.circles-red::before{color: #e14; content: '❌︎'}
|
||||||
|
.circles-list > li.circles-orange::before{color: #f72; content: '△'}
|
||||||
|
.circles-list > li.circles-yellow::before{color: #fc6; content: '◇'}
|
||||||
|
.circles-list > li.circles-green::before{color: #6e4; content: '○'}
|
||||||
|
.circles-add-form{display:table}
|
||||||
|
.circles-add-form > div{display:table-row}
|
||||||
|
.circles-add-form > div > *{display:table-cell}
|
||||||
|
.circles-add-form > div > label{text-align:right}
|
||||||
|
|
||||||
|
|
||||||
/* floating elements */
|
/* floating elements */
|
||||||
|
|
|
||||||
57
templates/circles/add.html
Normal file
57
templates/circles/add.html
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Circles – {{ app_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<form method="POST" class="circles-add-form">
|
||||||
|
<div>
|
||||||
|
<label>#</label>
|
||||||
|
<input type="number" name="code" placeholder="00000" required="" maxlength="6" value="{{ pl.code if pl else '' }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Display name</label>
|
||||||
|
<input type="text" name="display_name" placeholder="Display name" required="" value="{{ pl.display_name if pl else '' }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>First name</label>
|
||||||
|
<input type="text" name="first_name" placeholder="First name" value="{{ pl.first_name if pl else '' }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Last name</label>
|
||||||
|
<input type="text" name="last_name" placeholder="Last name" value="{{ pl.last_name if pl else '' }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Type</label>
|
||||||
|
<select name="type">
|
||||||
|
<option disabled=""{% if not pl %} selected=""{% endif %}>(Choose)</option>
|
||||||
|
{% set typ_list = [
|
||||||
|
'INTJ', 'INTP', 'INFJ', 'INFP', 'ENTJ', 'ENTP',
|
||||||
|
'ENFJ', 'ENFP', 'ISTJ', 'ISTP', 'ISFJ', 'ISFP',
|
||||||
|
'ESTJ', 'ESTP', 'ESFJ', 'ESFP', '1x38B'] %}
|
||||||
|
{% for typ in typ_list %}
|
||||||
|
<option value="{{ typ }}"{% if pl and typ == pl.type %} selected=""{% endif %}>{{ typ }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Status</label>
|
||||||
|
<select name="status">
|
||||||
|
<option value="-1">Red</option>
|
||||||
|
<option value="0"{% if pl and 0 == pl.status %} selected=""{% endif %}>Orange</option>
|
||||||
|
<option value="1"{% if pl and 1 == pl.status %} selected=""{% endif %}>Yellow</option>
|
||||||
|
<option value="2"{% if pl and 2 == pl.status %} selected=""{% endif %}>Green</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Area</label>
|
||||||
|
<input type="number" name="area" value="{{ pl.area if pl and pl.area else 0 }}">
|
||||||
|
</div>
|
||||||
|
<input type="submit" value="Save">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if not pl %}
|
||||||
|
<p>Looking for mass addition? Try <a href="/circles/csv">CSV adding form</a> instead.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
21
templates/circles/csv.html
Normal file
21
templates/circles/csv.html
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Circles – {{ app_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<p>Enter the contacts you want to bulk add, in comma-separated values (CSV) format, one by line.<br />Order matters.</p>
|
||||||
|
<textarea name="text" style="width:100%;height:20em" placeholder="00000,First,Last,Display,Type"></textarea>
|
||||||
|
<input type="checkbox" disabled="" id="autoConsent" name="consent" value="y" />
|
||||||
|
<input type="submit" value="Save" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
setTimeout(() => {
|
||||||
|
let ac = document.getElementById("autoConsent");
|
||||||
|
ac.disabled = false;
|
||||||
|
ac.selected = false;
|
||||||
|
}, 10000)
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
60
templates/circles/list.html
Normal file
60
templates/circles/list.html
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Circles – {{ app_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>Showing: <strong>{{ cat }}</strong></p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>Show by:</p>
|
||||||
|
<p><strong>Type</strong>:
|
||||||
|
{% set typ_list = [
|
||||||
|
'INTJ', 'INTP', 'INFJ', 'INFP', 'ENTJ', 'ENTP',
|
||||||
|
'ENFJ', 'ENFP', 'ISTJ', 'ISTP', 'ISFJ', 'ISFP',
|
||||||
|
'ESTJ', 'ESTP', 'ESFJ', 'ESFP'] %}
|
||||||
|
{% for t in typ_list %}
|
||||||
|
<a href="/circles/{{ t.lower() }}">{{ t }}</a> ·
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
<p><strong>Status</strong>:
|
||||||
|
{% set typ_list = ['Green', 'Yellow', 'Orange', 'Red'] %}
|
||||||
|
{% for t in typ_list %}
|
||||||
|
<a href="/circles/{{ t.lower() }}">{{ t }}</a> ·
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
<p><strong>Area</strong>:
|
||||||
|
{% for t in range(1, 13) %}
|
||||||
|
<a href="/circles/area-{{ t }}">{{ t }}</a> ·
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if count > people.count() %}
|
||||||
|
<p>Showing <strong>{{ people.count() }}</strong> people of <strong>{{ count }}</strong> total.</p>
|
||||||
|
{% if count > pageno * 50 %}
|
||||||
|
<p><a href="?page={{ pageno + 1 }}" rel="nofollow">Next page</a>{% if pageno > 1 %} · <a href="?page={{ pageno - 1 }}" rel="nofollow">Prev page</a>{% endif %}</p>
|
||||||
|
{% elif pageno > 1 %}
|
||||||
|
<a href="?page={{ pageno - 1 }}" rel="nofollow">Prev page</a></p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p><strong>{{ count }}</strong> people.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<ul class="circles-list">
|
||||||
|
{% for p in people %}
|
||||||
|
<li class="circles-{{ 'red' if p.status == -1 else 'green' if p.status == 2 else 'yellow' if p.status == 1 else 'orange' }}">{{ p.code }} – {{ p.display_name }} – {{ p.type }} – {% if p.area %}Area {{ p.area }}{% else %}No area{% endif %} (<a href="/circles/edit/{{ p.code }}">edit</a>)</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if count > people.count() %}
|
||||||
|
<p>Showing <strong>{{ people.count() }}</strong> people of <strong>{{ count }}</strong> total.</p>
|
||||||
|
{% if count > pageno * 50 %}
|
||||||
|
<p><a href="?page={{ pageno + 1 }}" rel="nofollow">Next page</a>{% if pageno > 1 %} · <a href="?page={{ pageno - 1 }}" rel="nofollow">Prev page</a>{% endif %}</p>
|
||||||
|
{% elif pageno > 1 %}
|
||||||
|
<a href="?page={{ pageno - 1 }}" rel="nofollow">Prev page</a></p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p><strong>{{ count }}</strong> people.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
23
templates/circles/stats.html
Normal file
23
templates/circles/stats.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Circles – {{ app_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Stats</h1>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>All people: <strong>{{ count }}</strong></li>
|
||||||
|
<li>People by type:</li>
|
||||||
|
<ul>
|
||||||
|
{% for k in typed_count.items() %}
|
||||||
|
<li>{{ k[0] }}: <strong>{{ k[1] }}</strong></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<li>People by status zone:</li>
|
||||||
|
<ul>
|
||||||
|
{% for k in status_count.items() %}
|
||||||
|
<li>{{ k[0] }}: <strong>{{ k[1] }}</strong></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue