covid-formity/formity/admin.py

542 lines
24 KiB
Python

import enum
import io
import datetime
import csv
from flask import redirect, flash, request, url_for, make_response, current_app, session, has_request_context
import flask_admin
from flask_admin.model.template import macro
import pdfplumber
from flask_admin.form import rules
from formity.extensions import admin, db, ModelView, ModelViewHighSecurity, AdminSecurityMixin
from wtforms import TextAreaField, RadioField, validators
from formity.models import FaceshieldRequest, RequestChange, Status, PostalCode, ExternalUser, Orga
from spaceauth import current_user
from flask_weasyprint import HTML, render_pdf
from sqlalchemy import func
class IndexView(AdminSecurityMixin, flask_admin.AdminIndexView):
@flask_admin.expose('/')
def index(self):
stats = FaceshieldRequest.query.with_entities(
FaceshieldRequest.status.label('status'),
FaceshieldRequest.handling_orga.label('handling_orga'),
func.count().label('count'),
func.sum(FaceshieldRequest.faceshield_full_required).label('faceshield_full_required'),
func.sum(FaceshieldRequest.faceshield_full_delivered).label('faceshield_full_delivered'),
func.sum(FaceshieldRequest.faceshield_front_required).label('faceshield_front_required'),
func.sum(FaceshieldRequest.faceshield_front_delivered).label('faceshield_front_delivered'),
).filter(FaceshieldRequest.status != Status.rejected, FaceshieldRequest.status != Status.spam).group_by(FaceshieldRequest.status, FaceshieldRequest.handling_orga).order_by(FaceshieldRequest.status, FaceshieldRequest.handling_orga).all()
vstats = FaceshieldRequest.query.select_from(FaceshieldRequest).with_entities(
func.coalesce(PostalCode.voivodeship, 'unknown').label('voivodeship'),
func.count().label('count'),
func.sum(FaceshieldRequest.faceshield_full_required).label('faceshield_full_required'),
func.sum(FaceshieldRequest.faceshield_full_delivered).label('faceshield_full_delivered'),
func.sum(FaceshieldRequest.faceshield_front_required).label('faceshield_front_required'),
func.sum(FaceshieldRequest.faceshield_front_delivered).label('faceshield_front_delivered'),
).filter(
FaceshieldRequest.status != Status.rejected,
FaceshieldRequest.status != Status.spam,
).join(FaceshieldRequest.postalcode_info, isouter=True).group_by('voivodeship').order_by(func.count().desc()).all()
dstats = FaceshieldRequest.query.select_from(FaceshieldRequest).with_entities(
func.date_trunc('day', FaceshieldRequest.created).label('date'),
func.count().label('count'),
).filter(FaceshieldRequest.status != Status.rejected, FaceshieldRequest.status != Status.spam).group_by(func.date_trunc('day', FaceshieldRequest.created)).order_by(func.date_trunc('day', FaceshieldRequest.created)).all()
return self.render('admin_index.html', stats=stats, vstats=vstats, dstats=dstats)
class FaceshieldRequestAdmin(ModelView):
column_default_sort = 'created'
details_modal_template = 'changelog_details_modal.html'
create_template = 'changelog_edit.html'
edit_template = 'changelog_edit.html'
list_template = 'faceshieldrequest_list.html'
column_searchable_list = ('id', 'email', 'remarks', 'extra', 'entity_info', 'full_name')
column_filters = (
'id',
'entity_info', 'full_name', 'phone_number', 'email', 'extra',
'faceshield_front_required', 'faceshield_model',
'faceshield_front_delivered',
'faceshield_full_required', 'faceshield_full_delivered',
'adapter_3m_dar_required', 'adapter_3m_dar_delivered',
'adapter_easybreath_dar_required', 'adapter_easybreath_dar_delivered',
'adapter_rd40_dar_required', 'adapter_rd40_dar_delivered',
'adapter_secura_dar_required', 'adapter_secura_dar_delivered',
'handling_orga',
'created',
# nie dla psa x-D
# 'ua', 'ip',
'status', 'remarks',
'parent_id',
'shipping_name', 'shipping_street', 'shipping_postalcode', 'shipping_city',
'shipping_latitude', 'shipping_longitude', 'shipping_provider', 'shipping_id',
'postalcode_info',
)
form_create_rules = form_edit_rules = (
rules.Macro('render_submit'),
rules.Container('row', rules.NestedRule([
rules.Container('split', rules.FieldSet((
'status',
'handling_orga',
'entity_info',
'full_name',
'phone_number',
'email',
rules.Macro('render_readonly', name='Extra', field='extra'),
'remarks',
rules.Macro('render_readonly', name='Created', field='created'),
rules.Macro('render_readonly', name='Updated', field='updated'),
rules.Macro('render_readonly', name='IP', field='ip'),
rules.Macro('render_readonly', name='UA', field='ua'),
), 'Request')),
rules.Container('split', rules.FieldSet((
'delivery_method',
'shipping_name',
'shipping_street',
'shipping_postalcode',
'shipping_city',
'shipping_latitude',
'shipping_longitude',
'parent_shipment',
'children',
'shipping_provider',
'shipping_id',
), 'Shipping information')),
])),
rules.Container('split', rules.FieldSet((
'faceshield_full_required',
'faceshield_front_required',
'adapter_3m_dar_required',
'adapter_easybreath_dar_required',
'adapter_rd40_dar_required',
'adapter_secura_dar_required',
'faceshield_model',
), 'Order')),
rules.Container('split', rules.FieldSet((
'faceshield_full_delivered',
'faceshield_front_delivered',
'adapter_3m_dar_delivered',
'adapter_easybreath_dar_delivered',
'adapter_rd40_dar_delivered',
'adapter_secura_dar_delivered',
), 'Delivered')),
)
form_choices = {
'handling_orga': [
('hswaw', 'hswaw'),
('hskrk', 'hskrk'),
],
'delivery_method': [
('shipping', 'Shipping'),
('pickup', 'Pickup'),
],
'shipping_provider': [
('kurjerzy', 'Kurjerzy'),
('xbs', 'XBS Group'),
],
}
form_overrides = {'entity_info': TextAreaField, 'extra': TextAreaField, 'remarks': TextAreaField, 'delivery_method': RadioField}
form_args = {
'shipping_name': {'validators': [validators.Length(max=35)]},
'delivery_method': {'choices': form_choices['delivery_method']},
}
form_widget_args = {
'delivery_method': { 'class': 'radio' },
}
form_excluded_columns = ('handling_orga_info', 'changelog', 'postalcode_info')
column_list = ('id', 'entity_info', 'full_name', 'items_total_delivered', 'handling_orga', 'created', 'status')
column_sortable_list = column_list
column_editable_list = ('status', 'remarks', 'handling_orga')
column_formatters = {'items_total_delivered': macro('render_items')}
column_labels = {'items_total_delivered': 'Items'}
column_export_list = column_filters
column_labels = {
'faceshield_front_required': 'Front required',
'faceshield_full_required': 'Full required',
'faceshield_front_delivered': 'Front delivered',
'faceshield_full_delivered': 'Full delivered',
}
allowed_columns = None
extra_allowed_columns = ('id',)
@property
def _list_columns(self):
self.allowed_columns = self.allowed_columns or (self.scaffold_list_columns() + list(self.extra_allowed_columns))
columns = self.column_list or self.scaffold_list_columns()
if has_request_context() and request.args.get('columns'):
columns = [c for c in request.args.get('columns').split(',') if c in self.allowed_columns]
return self.get_column_names(
only_columns=columns,
excluded_columns=self.column_exclude_list,
)
@_list_columns.setter
def _list_columns(self, value):
return None
can_delete = False
can_view_details = True
details_modal = True
can_set_page_size = True
can_export = True
@flask_admin.expose('/label/')
def label(self):
return render_pdf(HTML(string=self.label_html()))
@flask_admin.expose('/label/html')
def label_html(self):
model = self.get_one(request.args.get('id'))
return self.render('label.html', models=[model])
@flask_admin.expose('/shipmentinfo')
def tracking(self):
model = self.get_one(request.args.get('id'))
return self.render('admin/tracking.html', models=[model])
@flask_admin.expose('/import-dpd', methods=['POST', 'GET'])
def dpd_import(self):
if request.method == 'POST':
# check if the post request has the file part
if request.form.get('accept') == '1':
changed = 0
for k, v in session.get('dpd-import', {}).items():
m = FaceshieldRequest.query.filter(FaceshieldRequest.id == k).first()
if not m:
flash('Request #{} not found'.format(k), 'warning')
continue
m.shipping_id = ','.join(v)
m.shipping_provider = 'xbs'
changed += 1
db.session.commit()
flash('{} requests changed'.format(changed), 'info')
session.pop('dpd-import')
return redirect(request.url)
if 'file' not in request.files:
flash('No file uploaded', 'error')
return redirect(request.url)
file = request.files['file']
if file.filename == '':
flash('No selected file', 'error')
return redirect(request.url)
p = pdfplumber.load(file.stream)
data = self.parse_dpd(p)
session['dpd-import'] = {k: list(v) for k, v in data.items()}
return self.render('admin/dpd_pdf_import.html', data=session['dpd-import'])
return self.render('admin/dpd_pdf_import.html')
def parse_dpd(self, pdf):
data = {}
for page in pdf.pages:
table = page.extract_table()
if table[0] != [None, None, None, None, 'MIEJSCE DORĘCZENIA PACZKI', None, None, None, None, None]:
raise Exception('Invalid header (1/2)')
if table[1] != ['LP', 'Numkat', 'NUMER PACZKI', 'NUMER REFERENCYJNY\n( np. numer faktury, zamówienia itp.)', 'OPIS', 'Odbiorca', 'Wartość\nprzesyłki', 'KWOTA\nCOD PLN', 'Waga dekl.\n(kg)', 'Zawartość']:
raise Exception('Invalid header (2/2)')
for row in table[2:]:
ref = row[3].split('/')[0]
tracking = row[2]
if ref not in data:
data[ref] = set()
data[ref].add(tracking)
return data
def bulk_status_change(self, ids, status):
try:
query = self.get_query().filter(FaceshieldRequest.id.in_(ids))
count = 0
for req in query.all():
if req.status != status:
req.status = status
count += 1
db.session.commit()
flash('{} requests were successfully marked as {}.'.format(count, status.name))
except Exception as ex:
if not self.handle_view_exception(ex):
raise
flash('Failed to mark request as {}: {}'.format(status.name, str(ex)), 'error')
@flask_admin.actions.action('new', 'Mark as new', 'Are you sure you want to mark as new?')
def action_new(self, ids):
self.bulk_status_change(ids, Status.new)
@flask_admin.actions.action('fulfilled', 'Mark as fulfilled', 'Are you sure you want to mark as fulfilled?')
def action_fulfilled(self, ids):
self.bulk_status_change(ids, Status.fulfilled)
@flask_admin.actions.action('intransit', 'Mark as intransit', 'Are you sure you want to mark as intransit?')
def action_intransit(self, ids):
self.bulk_status_change(ids, Status.intransit)
@flask_admin.actions.action('shippingpending', 'Mark as shippingpending', 'Are you sure you want to mark as shippingpending?')
def action_shippingpending(self, ids):
self.bulk_status_change(ids, Status.shippingpending)
@flask_admin.actions.action('pickuppending', 'Mark as pickuppending', 'Are you sure you want to mark as pickuppending?')
def action_pickuppending(self, ids):
self.bulk_status_change(ids, Status.pickuppending)
@flask_admin.actions.action('bulkprint', 'Print')
def action_bulkprint(self, ids):
models = self.get_query().filter(FaceshieldRequest.id.in_(ids)).all()
return render_pdf(HTML(string=self.render('label.html', models=models)))
@flask_admin.actions.action('csv_kurjerzy', 'Export Kurjerzy.pl CSV')
def action_csv_kurjerzy(self, ids):
models = self.get_query().filter(FaceshieldRequest.id.in_(ids)).all()
fields = [
'kurier',
'nadawca_nazwa', 'nadawca_email', 'nadawca_ulica',
'nadawca_nr_lok', 'nadawca_kod', 'nadawca_miasto',
'nadawca_telefon', 'nadawca_kraj', 'nadawca_region',
'nadawca_punkt',
'odbiorca_nazwa', 'odbiorca_email', 'odbiorca_ulica',
'odbiorca_nr_lok', 'odbiorca_kod', 'odbiorca_miasto',
'odbiorca_telefon', 'odbiorca_kraj', 'odbiorca_region',
'odbiorca_punkt',
'przesylka_rodzaj', 'przesylka_opakowanie', 'przesylka_waga',
'przesylka_szerokosc', 'przesylka_wysokosc', 'przesylka_dlugosc',
'przesylka_wartosc', 'przesylka_zawartosc',
'pobranie_wartosc', 'pobranie_nr_bank', 'pobranie_1_dzien',
'sms_nadawca', 'sms_odbiorca', 'dostawa_nast_dnia',
'dokumenty_zwrotne', 'bez_odbioru', 'numer_ref', 'kod_promo'
]
fname = 'kurjerzy-export-%s.csv' % (datetime.datetime.now().strftime(r'%Y%m%d-%H%M%S'),)
si = io.StringIO()
writer = csv.DictWriter(si, fields, delimiter=';')
writer.writeheader()
for row in models:
writer.writerow({
'kurier': 'ups',
'nadawca_nazwa': row.handling_orga_info.shipping_name,
'nadawca_email': row.handling_orga_info.shipping_email,
'nadawca_ulica': row.handling_orga_info.shipping_street,
'nadawca_nr_lok': row.handling_orga_info.shipping_number,
'nadawca_kod': row.handling_orga_info.shipping_postalcode,
'nadawca_miasto': row.handling_orga_info.shipping_city,
'nadawca_telefon': row.handling_orga_info.shipping_phone_number,
'nadawca_kraj': 'PL',
'odbiorca_nazwa': row.shipping_name,
'odbiorca_email': row.email,
'odbiorca_ulica': row.shipping_street,
'odbiorca_nr_lok': '1/1', # FIXME
'odbiorca_kod': row.shipping_postalcode,
'odbiorca_miasto': row.shipping_city,
'odbiorca_telefon': row.phone_number,
'odbiorca_kraj': 'PL',
# TODO
'przesylka_rodzaj': 1,
'przesylka_opakowanie': 1,
'przesylka_waga': 4,
'przesylka_szerokosc': 40,
'przesylka_wysokosc': 30,
'przesylka_dlugosc': 20,
'przesylka_wartosc': 100,
'przesylka_zawartosc': 'przyłbice dla medyków %d' % (row.id,),
'sms_odbiorca': 1,
'dostawa_nast_dnia': 1,
})
row.changelog.append(RequestChange(
remarks='included on Kurjerzy export (%s)' % (fname,),
))
db.session.commit()
output = make_response(si.getvalue())
output.headers["Content-Disposition"] = "attachment; filename=%s" % (fname,)
output.headers["Content-type"] = "text/csv"
return output
@flask_admin.actions.action('csv_xbs', 'Export XBS Group/DPD CSV')
def action_csv_xbs(self, ids):
models = self.get_query().filter(FaceshieldRequest.id.in_(ids)).all()
fields = [
'NAZWA NADAWCY', 'OSOBA KONTAKTOWA NADAWCA',
'TELEFON KONTAKTOWY NADAWCA ', 'ULICA NADAWCA',
'KOD POCZTOWY NADAWCA', 'MIASTO NADAWCA',
'NAZWA ODBIORCA', 'OSOBA KONTAKTOWA ODBIORCA',
'TELEFON KONTAKTOWYODBIORCA', 'ULICA ODBIORCA',
'KOD POCZTOWY ODBIORCA', 'MIASTO ODBIORCA',
'WAGA', 'ILOŚĆ PACZEK', 'NUMKAT', 'NEXT DAY', 'SOBOTA',
'GWARANT 9:30', 'GWARANT 12:00', 'DZ', 'COD', 'WARTOŚĆ',
'NR REFERENCYJNY 1', 'NR REFERENCYJNY 2',
'ZAWARTOŚĆ', 'UWAGII',
]
fname = 'xbs-dpd-export-%s.csv' % (datetime.datetime.now().strftime(r'%Y%m%d-%H%M%S'),)
si = io.StringIO()
writer = csv.DictWriter(si, fields)
writer.writeheader()
for row in models:
for label_id in range(row.label_count):
writer.writerow({
'NAZWA NADAWCY': row.handling_orga_info.shipping_name,
'OSOBA KONTAKTOWA NADAWCA': row.handling_orga_info.shipping_full_name,
'ULICA NADAWCA': '%s %s' % (row.handling_orga_info.shipping_street, row.handling_orga_info.shipping_number),
'KOD POCZTOWY NADAWCA': row.handling_orga_info.shipping_postalcode,
'MIASTO NADAWCA': row.handling_orga_info.shipping_city,
'TELEFON KONTAKTOWY NADAWCA ': row.handling_orga_info.shipping_phone_number,
'NAZWA ODBIORCA': row.shipping_name,
'OSOBA KONTAKTOWA ODBIORCA': row.full_name,
'TELEFON KONTAKTOWYODBIORCA': row.phone_number,
'ULICA ODBIORCA': row.shipping_street,
'KOD POCZTOWY ODBIORCA': row.shipping_postalcode,
'MIASTO ODBIORCA': row.shipping_city,
# TODO kartony?
'WAGA': 20,
'ILOŚĆ PACZEK': 1,
'NR REFERENCYJNY 1': '%s/%s/%s' % (row.id, label_id + 1, row.label_count),
'ZAWARTOŚĆ': 'przyłbice dla medyków',
})
row.changelog.append(RequestChange(
remarks='included on XBS export (%s)' % (fname,),
))
db.session.commit()
output = make_response(si.getvalue())
output.headers["Content-Disposition"] = "attachment; filename=%s" % (fname,)
output.headers["Content-type"] = "text/csv"
return output
@flask_admin.actions.action('shipping_create', 'Kurjerzy / Create shipment')
def action_shipping_create(self, ids):
from shipping.kurjerzy import Kurjerzy
models = self.get_query().filter(FaceshieldRequest.id.in_(ids)).all()
k = Kurjerzy(current_app)
k.authenticate()
created = 0
for model in models:
try:
if model.shipping_id is not None or model.shipping_provider is not None:
if k.shipment_info(model.shipping_id):
flash('Ignoring #%s (shipment already created)' % (model.id,), 'warning')
continue
k.create_shipment(model)
db.session.commit()
created += 1
except Exception as exc:
flash('Shipment creation failed for #%s: %s' % (model.id, exc), 'error')
return
if created:
flash('%d shipments created' % (created,), 'info')
@flask_admin.actions.action('shipping_print', 'Kurjerzy / Print shipping labels')
def action_shipping_print(self, ids):
models = self.get_query().filter(FaceshieldRequest.id.in_(ids)).all()
return render_pdf(HTML(string=self.render('shipping_label.html', models=models)))
@flask_admin.actions.action('shipping_refresh', 'Shipping / Refresh tracking info')
def action_shipping_refresh(self, ids):
models = self.get_query().filter(FaceshieldRequest.id.in_(ids)).all()
for m in models:
m.refresh_shipping_info()
class FilteredFaceshieldRequestAdmin(FaceshieldRequestAdmin):
def get_query(self):
return super(FilteredFaceshieldRequestAdmin, self).get_query().filter(~FaceshieldRequest.status.in_([Status.rejected, Status.spam, Status.fulfilled, Status.delegated]))
def get_count_query(self):
return super(FilteredFaceshieldRequestAdmin, self).get_count_query().filter(~FaceshieldRequest.status.in_([Status.rejected, Status.spam, Status.fulfilled, Status.delegated]))
class ShippingFaceshieldRequestAdmin(FilteredFaceshieldRequestAdmin):
column_editable_list = ('shipping_name', 'shipping_street', 'shipping_postalcode', 'shipping_city', 'shipping_latitude', 'shipping_longitude', 'status')
column_list = ['id', 'entity_info', 'full_name', *column_editable_list]
class ExternalUserAdmin(ModelViewHighSecurity):
column_default_sort = 'id'
column_list = ('id', 'email', 'password', 'remarks', 'orga')
form_columns = ('email', 'password', 'remarks', 'orga')
can_delete = True
class OrgaAdmin(ModelViewHighSecurity):
column_default_sort = 'id'
column_list = ('id', 'name')
form_columns = ('id', 'name', 'shipping_name', 'shipping_full_name', 'shipping_street', 'shipping_number', 'shipping_postalcode', 'shipping_city', 'shipping_phone_number', 'shipping_email')
class ChangelogAdmin(ModelView):
can_delete = False
can_edit = False
can_set_page_size = True
can_create = False
column_list = ('user_id', 'request', 'state_after', 'created')
column_filters = ('request_id', 'request', 'user_id', 'created')
column_default_sort = ('created', True)
class MapView(FaceshieldRequestAdmin):
list_template = 'admin_map.html'
page_size = 0
can_set_page_size = False
can_create = False
can_export = False
def get_query(self):
return super(MapView, self).get_query().filter(~FaceshieldRequest.status.in_([Status.rejected, Status.spam]))
def get_list(self, page, sort_field, sort_desc, search, filters, page_size=None):
count, data = super(MapView, self).get_list(page, sort_field, sort_desc, search, filters, page_size)
return count, [
{
key: getattr(request, key).name if isinstance(getattr(request, key), enum.Enum) else getattr(request, key)
for key in ['id', 'entity_info', 'shipping_latitude', 'shipping_longitude', 'status', 'handling_orga', 'extra', 'remarks', 'faceshield_full_required']
}
for request in data
]
def get_pk_value(self, model):
return model['id']
def is_action_allowed(self, name):
return False
admin.add_view(FilteredFaceshieldRequestAdmin(FaceshieldRequest, db.session))
admin.add_view(FaceshieldRequestAdmin(FaceshieldRequest, db.session, name='FaceshieldRequest (Unfiltered)', endpoint='request_unfiltered'))
admin.add_view(ShippingFaceshieldRequestAdmin(FaceshieldRequest, db.session, name='FaceshieldRequest (Shipping)', endpoint='request_shipping'))
admin.add_view(ExternalUserAdmin(ExternalUser, db.session, name='External Users', endpoint='external_user'))
admin.add_view(OrgaAdmin(Orga, db.session))
admin.add_view(MapView(FaceshieldRequest, db.session, name='Map', endpoint='map'))
admin.add_view(ChangelogAdmin(RequestChange, db.session))