Merge branch 'markup_tool'
This commit is contained in:
@ -24,8 +24,8 @@ def catalogedit(request, id=0):
|
||||
seasons = Season.objects.order_by('ordering')
|
||||
context = {
|
||||
'catalogID': id,
|
||||
'regions': [r.serialize() for r in regions],
|
||||
'seasons': [s.serialize() for s in seasons],
|
||||
'regions': [r.serialize() for r in regions if r.visible],
|
||||
'seasons': [s.serialize() for s in seasons][::-1], # reversed
|
||||
}
|
||||
return render(request, 'catalogedit/catalogedit.html', context)
|
||||
|
||||
|
||||
@ -27,6 +27,6 @@ def my_catalogs(request):
|
||||
|
||||
@login_required
|
||||
def public_catalogs(request):
|
||||
cats = Catalog.objects.filter(public=True).exclude(owner=request.user).order_by('-updated')
|
||||
cats = Catalog.objects.filter(public=True).order_by('-updated')
|
||||
data = [c.summary() for c in cats]
|
||||
return JsonResponse({'catalogs': data})
|
||||
|
||||
0
markup/__init__.py
Normal file
0
markup/__init__.py
Normal file
109
markup/email.py
Normal file
109
markup/email.py
Normal file
@ -0,0 +1,109 @@
|
||||
import sys
|
||||
import string
|
||||
import random
|
||||
import smtplib
|
||||
from pathlib import Path
|
||||
from mailbox import Maildir
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate
|
||||
from email.header import Header, make_header
|
||||
|
||||
from procat2.settings import EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD
|
||||
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
body_ok = """Hi,
|
||||
|
||||
Attached is a copy of your marked up catalog and a spreadsheet with
|
||||
the articles you selected.
|
||||
|
||||
Enjoy,
|
||||
ProCatalog Markup Bot
|
||||
"""
|
||||
|
||||
body_missing = """Hi,
|
||||
|
||||
I couldn't find a pdf attached to your message. I can't do much
|
||||
without a marked up catalog pdf, so please include that when you try
|
||||
again.
|
||||
|
||||
Thanks,
|
||||
ProCatalog Markup Bot
|
||||
"""
|
||||
|
||||
body_no_matches = """Hi,
|
||||
|
||||
I couldn't find any products marked in your pdf. Make sure you're
|
||||
using a ProCatalog pdf and that you've circled or otherwise scribbled
|
||||
over some material images or SKUs before submitting.
|
||||
|
||||
Thanks,
|
||||
ProCatalog Markup Bot
|
||||
"""
|
||||
|
||||
|
||||
def reply(frm, subj, xls_path, pdf_path):
|
||||
msg = EmailMessage()
|
||||
msg.set_content(body_ok)
|
||||
subj = f'Re: {subj}'
|
||||
|
||||
with open(xls_path, 'rb') as fp:
|
||||
msg.add_attachment(fp.read(),
|
||||
maintype='application',
|
||||
subtype='vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
filename=Path(xls_path).name)
|
||||
|
||||
with open(pdf_path, 'rb') as fp:
|
||||
msg.add_attachment(fp.read(),
|
||||
maintype='application',
|
||||
subtype='pdf',
|
||||
filename=Path(pdf_path).name)
|
||||
|
||||
send(frm, subj, msg)
|
||||
|
||||
|
||||
def reply_missing(frm, subj):
|
||||
msg = EmailMessage()
|
||||
msg.set_content(body_missing)
|
||||
subj = f'Re: {subj}'
|
||||
send(frm, subj, msg)
|
||||
|
||||
|
||||
def reply_no_matches(frm, subj):
|
||||
msg = EmailMessage()
|
||||
msg.set_content(body_no_matches)
|
||||
subj = f'Re: {subj}'
|
||||
send(frm, subj, msg)
|
||||
|
||||
|
||||
def send_error_email(subj, einfo):
|
||||
msg = EmailMessage()
|
||||
msg.set_content(einfo)
|
||||
send('error@procatalog.io', subj, msg)
|
||||
|
||||
|
||||
def send(frm, subj, msg):
|
||||
msg['From'] = 'Keen ProCatalog Markup Bot <markup@procatalog.io>'
|
||||
msg['Reply-To'] = 'Keen ProCatalog Support <support@procatalog.io>'
|
||||
msg['To'] = frm
|
||||
msg['Bcc'] = 'alx-markup@procatalog.io'
|
||||
msg['Subject'] = Header(subj).encode()
|
||||
msg['Message-ID'] = msgid()
|
||||
msg['Date'] = formatdate()
|
||||
|
||||
maildir = Maildir('/tmp/markup_submit_mail')
|
||||
maildir.add(msg.as_bytes())
|
||||
|
||||
log.info(f'sending email to "{frm}": {subj}')
|
||||
|
||||
with smtplib.SMTP(EMAIL_HOST) as s:
|
||||
s.starttls()
|
||||
s.login(EMAIL_HOST_USER, EMAIL_HOST_PASSWORD)
|
||||
s.send_message(msg)
|
||||
|
||||
|
||||
def msgid():
|
||||
rand = ''.join(random.choices(string.ascii_uppercase + string.ascii_lowercase + string.digits, k=16))
|
||||
return f'<{rand}@markup.procatalog.io>'
|
||||
108
markup/img.py
Normal file
108
markup/img.py
Normal file
@ -0,0 +1,108 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from PIL import Image, ImageFilter, ImageDraw, ImageFont
|
||||
import numpy
|
||||
import imutils
|
||||
import cv2
|
||||
import dumper
|
||||
import random as rng
|
||||
from pathlib import Path
|
||||
|
||||
from .utils import cv2_rect, ensure_dir, set_file_perms, WORKDIR
|
||||
|
||||
# https://www.pyimagesearch.com/2014/10/20/finding-shapes-images-using-python-opencv/
|
||||
|
||||
|
||||
def find_shapes(image_path):
|
||||
"""Find shapes in the image, returning bounding boxes around each.
|
||||
Writes debug images next to the input image.
|
||||
"""
|
||||
path = Path(image_path)
|
||||
|
||||
img = Image.open(image_path, 'r')
|
||||
if not img.mode in ('RGBA', 'LA'):
|
||||
print('no alpha channel: {}'.format(img.mode))
|
||||
return None
|
||||
|
||||
alpha_layer = img.convert('RGBA').split()[-1]
|
||||
|
||||
alpha_layer = alpha_layer.filter(ImageFilter.GaussianBlur(5))
|
||||
|
||||
threshold = 5
|
||||
alpha_layer = alpha_layer.point(lambda p: p > threshold and 255)
|
||||
threshold = numpy.array(alpha_layer)
|
||||
|
||||
# alternate method
|
||||
# blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||
# thresh = cv2.threshold(blurred, 60, 255, cv2.THRESH_BINARY)[1]
|
||||
|
||||
thresh_path = str(path.with_suffix('.thresh.png'))
|
||||
cv2.imwrite(thresh_path, threshold)
|
||||
os.chmod(thresh_path, 0o664)
|
||||
shutil.chown(thresh_path, group='procat')
|
||||
|
||||
contours = cv2.findContours(threshold, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
contours = imutils.grab_contours(contours)
|
||||
|
||||
bboxes = []
|
||||
for c in contours:
|
||||
# bounding rect
|
||||
x, y, w, h = cv2.boundingRect(c)
|
||||
# essentially center of mass
|
||||
# NOT center of the bbox!
|
||||
# M = cv2.moments(c)
|
||||
# if M["m00"] == 0: M["m00"] = 0.00001
|
||||
# cX = int(M["m10"] / M["m00"])
|
||||
# cY = int(M["m01"] / M["m00"])
|
||||
bboxes.append(cv2_rect(x, y, w, h))
|
||||
|
||||
# draw contours
|
||||
contour_image = numpy.zeros((threshold.shape[0], threshold.shape[1], 3), dtype=numpy.uint8)
|
||||
for i in range(len(contours)):
|
||||
color = (rng.randint(0,512), rng.randint(0,512), rng.randint(0,512))
|
||||
cv2.drawContours(contour_image, contours, i, color)
|
||||
rect = bboxes[i]
|
||||
cv2.rectangle(contour_image, (rect.left, rect.top), (rect.right, rect.bottom), color, 1)
|
||||
# cv2.circle(contour_image, (cX, cY), 2, color, -1)
|
||||
# cv2.putText(contour_image, "center", (cX - 20, cY - 15),
|
||||
# cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
|
||||
|
||||
contour_path = str(path.with_suffix('.contour.png'))
|
||||
cv2.imwrite(contour_path, contour_image)
|
||||
os.chmod(contour_path, 0o664)
|
||||
shutil.chown(contour_path, group='procat')
|
||||
|
||||
return img.width, img.height, bboxes
|
||||
|
||||
|
||||
def write_debug_image(workdir, page_num, prods, scribbles):
|
||||
"""Draw an image with boxes for products, images, and shapes."""
|
||||
ensure_dir(workdir)
|
||||
path = os.path.join(workdir, f"debug-page{page_num:03d}.png")
|
||||
|
||||
pagew = int(11*72)
|
||||
pageh = int(8.5*72)
|
||||
|
||||
img = Image.new('RGBA', (pagew, pageh), 'white')
|
||||
draw = ImageDraw.Draw(img, 'RGBA')
|
||||
fnt = ImageFont.truetype('/usr/share/fonts/truetype/lato/Lato-Regular.ttf', 10)
|
||||
|
||||
for prod in filter(lambda p: p['page'] == page_num, prods):
|
||||
rect = prod['rect']
|
||||
fill_color = "hsv(120, 22%, 100%)" if 'matched' in prod else None
|
||||
outline_color = "hsv(120, 50%, 100%)"
|
||||
draw.rectangle((rect.p1(pageh), rect.p2(pageh)),
|
||||
fill=fill_color, outline=outline_color, width=2)
|
||||
bl = rect.p1(pageh)
|
||||
draw.text((bl[0] + 3, bl[1] + 3), prod['material'],
|
||||
font=fnt, fill="hsv(120, 22%, 50%)")
|
||||
|
||||
for scribble in filter(lambda s: s['page'] == page_num, scribbles):
|
||||
rect = scribble['rect']
|
||||
draw.rectangle((rect.p1(pageh), rect.p2(pageh)), outline="hsv(210, 22%, 100%)", width=2)
|
||||
for box in scribble['bboxes']:
|
||||
draw.rectangle((box.p1(pageh), box.p2(pageh)), outline="hsv(0, 22%, 100%)", width=2)
|
||||
|
||||
img.save(path)
|
||||
set_file_perms(path)
|
||||
62
markup/matching.py
Normal file
62
markup/matching.py
Normal file
@ -0,0 +1,62 @@
|
||||
from .img import find_shapes, write_debug_image
|
||||
from .pdf import parse_pdf
|
||||
from .utils import overlaps
|
||||
|
||||
|
||||
def find_marked_products(pdf, workdir, debug=0):
|
||||
"""Main entry point. Give a pdf, get matches."""
|
||||
(prods, scribbles) = parse_pdf(pdf, workdir, debug)
|
||||
|
||||
if not prods or len(prods) < 1:
|
||||
print('no product placement markers found')
|
||||
return None
|
||||
|
||||
find_scribbles_shapes(scribbles)
|
||||
matches = find_matches(prods, scribbles, 0.10)
|
||||
|
||||
for s in scribbles:
|
||||
write_debug_image(workdir, s['page'], prods, scribbles)
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def find_scribbles_shapes(scribbles):
|
||||
for scribble in scribbles:
|
||||
imgw, imgh, shapes = find_shapes(scribble['image'])
|
||||
rects = [transform(scribble['rect'], imgw, imgh, s) for s in shapes]
|
||||
scribble['bboxes'] = rects
|
||||
|
||||
|
||||
def transform(pdf_rect, imgw, imgh, shape):
|
||||
"""Convert scribble from image coords to pdf coords"""
|
||||
# get scale factor for image coords
|
||||
# to convert to pdf coordinates
|
||||
pdfw = pdf_rect.right - pdf_rect.left
|
||||
pdfh = pdf_rect.bottom - pdf_rect.top
|
||||
scalew = pdfw / imgw
|
||||
scaleh = pdfh / imgh
|
||||
return shape.scale(scalew, scaleh).translate(pdf_rect.left, pdf_rect.top)
|
||||
|
||||
|
||||
def find_matches(all_prods, scribbles, overlap_threshold):
|
||||
# segment by page
|
||||
page_prods = {}
|
||||
for p in all_prods:
|
||||
pagenum = p['page']
|
||||
if pagenum in page_prods:
|
||||
page_prods[pagenum].append(p)
|
||||
else:
|
||||
page_prods[pagenum] = [p]
|
||||
|
||||
matches = []
|
||||
for s in scribbles:
|
||||
pagenum = s['page']
|
||||
if not pagenum in page_prods: continue
|
||||
prods = page_prods[pagenum]
|
||||
for p in prods:
|
||||
for box in s['bboxes']:
|
||||
if overlaps(p['rect'], box, overlap_threshold):
|
||||
p['matched'] = s
|
||||
matches.append(p)
|
||||
|
||||
return matches
|
||||
127
markup/pdf.py
Normal file
127
markup/pdf.py
Normal file
@ -0,0 +1,127 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
from pdfminer.pdfparser import PDFParser
|
||||
from pdfminer.pdfdocument import PDFDocument
|
||||
from pdfminer.pdftypes import PDFObjRef, resolve1
|
||||
|
||||
from .utils import pdf_rect, ensure_dir, set_file_perms
|
||||
|
||||
|
||||
def make_product_box(obj, pagenum, mediabox):
|
||||
rect = obj['Rect']
|
||||
|
||||
if rect:
|
||||
name = obj['ProCatName'].decode() if 'ProCatName' in obj else ''
|
||||
material = obj['ProCatMaterialNumber'].decode() if 'ProCatMaterialNumber' in obj else ''
|
||||
color = obj['ProCatColor'].decode() if 'ProCatColor' in obj else ''
|
||||
gender = obj['ProCatGender'].decode() if 'ProCatGender' in obj else ''
|
||||
season = obj['ProCatSeason'].decode() if 'ProCatSeason' in obj else ''
|
||||
size = obj['ProCatSize'].decode() if 'ProCatSize' in obj else ''
|
||||
category = obj['ProCatCategory'].decode() if 'ProCatCategory' in obj else ''
|
||||
|
||||
return { 'material': material,
|
||||
'name': name,
|
||||
'color': color,
|
||||
'gender': gender,
|
||||
'season': season,
|
||||
'size': size,
|
||||
'category': category,
|
||||
'rect': pdf_rect(rect, mediabox[3]),
|
||||
'page': pagenum }
|
||||
else:
|
||||
print('Annotation without rect:')
|
||||
print(dumper.dump(obj))
|
||||
return None
|
||||
|
||||
|
||||
def make_scribble(obj, pagenum, mediabox, workdir):
|
||||
rect = obj['Rect'] # position on page
|
||||
|
||||
# walk the object tree down to the image
|
||||
appearance = resolve1(obj['AP'])
|
||||
normal_appearance = appearance['N']
|
||||
if not normal_appearance or normal_appearance.objid <= 0:
|
||||
print('skipping scribble - no normal appearance')
|
||||
return
|
||||
|
||||
normal_appearance = resolve1(normal_appearance)
|
||||
resources = resolve1(normal_appearance['Resources'])
|
||||
xobj = resolve1(resources['XObject'])
|
||||
im1 = resolve1(xobj['Im1']) # PDFStream of the image
|
||||
|
||||
flter = im1['Filter']
|
||||
if flter.name == 'JPXDecode':
|
||||
path = export_jp2(im1, workdir, pagenum)
|
||||
return { 'page': pagenum,
|
||||
'rect': pdf_rect(rect, mediabox[3]),
|
||||
'objid': im1.objid,
|
||||
'image': path }
|
||||
else:
|
||||
print('skipping non-jp2 image')
|
||||
return None
|
||||
|
||||
|
||||
def export_jp2(obj, workdir, pagenum):
|
||||
oid = obj.objid
|
||||
ensure_dir(workdir)
|
||||
jp2_path = os.path.join(workdir, f"export-page{pagenum:03d}-obj{oid:05d}.jp2")
|
||||
png_path = os.path.join(workdir, f"export-page{pagenum:03d}-obj{oid:05d}.png")
|
||||
|
||||
data = obj.get_rawdata()
|
||||
print('extracting jp2: {}'.format(jp2_path))
|
||||
with open(jp2_path, 'wb') as out:
|
||||
out.write(data)
|
||||
set_file_perms(jp2_path)
|
||||
|
||||
result = subprocess.run(['opj_decompress', '-i', jp2_path, '-o', png_path], capture_output=True)
|
||||
if result.returncode != 0:
|
||||
print('ERROR converting {}:\n{}\n{}'.format(jp2_path, result.stdout.decode(), result.stderr.decode()))
|
||||
else:
|
||||
set_file_perms(png_path)
|
||||
|
||||
return png_path
|
||||
|
||||
|
||||
def parse_pdf(fname, workdir, debug=0):
|
||||
PDFDocument.debug = debug
|
||||
PDFParser.debug = debug
|
||||
|
||||
fp = open(fname, 'rb')
|
||||
parser = PDFParser(fp)
|
||||
doc = PDFDocument(parser)
|
||||
|
||||
prod_boxes = []
|
||||
scribbles = []
|
||||
|
||||
page_dict = resolve1(doc.catalog['Pages'])
|
||||
pages = resolve1(page_dict['Kids'])
|
||||
pagenum = 0
|
||||
for page in pages:
|
||||
pagenum += 1
|
||||
page = resolve1(page)
|
||||
if not 'Annots' in page: continue
|
||||
|
||||
mediabox = page['MediaBox']
|
||||
# if 'CropBox' in page:
|
||||
# cropbox = page['CropBox']
|
||||
# print('crop',cropbox)
|
||||
|
||||
annots = page['Annots']
|
||||
if isinstance(annots, PDFObjRef):
|
||||
annots = resolve1(annots)
|
||||
|
||||
for anno in annots:
|
||||
anno = resolve1(anno)
|
||||
if 'AAPL:AKExtras' in anno:
|
||||
scribbles.append(make_scribble(anno, pagenum, mediabox, workdir))
|
||||
elif 'ProCatName' in anno:
|
||||
prod_boxes.append(make_product_box(anno, pagenum, mediabox))
|
||||
else:
|
||||
print('ignoring other annotation')
|
||||
|
||||
fp.close()
|
||||
|
||||
return [list(filter(None, prod_boxes)), list(filter(None, scribbles))]
|
||||
86
markup/spreadsheet.py
Normal file
86
markup/spreadsheet.py
Normal file
@ -0,0 +1,86 @@
|
||||
import os
|
||||
from itertools import zip_longest
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import PatternFill, Border, Side, Alignment, Protection, Font
|
||||
|
||||
from .utils import ensure_dir, set_file_perms, WORKDIR
|
||||
|
||||
|
||||
def format_season(s):
|
||||
if not s or len(s) < 4:
|
||||
return s
|
||||
|
||||
# 'FW20' -> 'F20'
|
||||
return s[:1] + s[2:]
|
||||
|
||||
|
||||
def format_name(name, gender):
|
||||
return '{}-{}'.format(name, gender[:1])
|
||||
|
||||
|
||||
def write_spreadsheet(matches, workdir, file_base):
|
||||
if not matches:
|
||||
print('write_spreadsheet: no matches. skipping.')
|
||||
return None
|
||||
|
||||
header_font = Font(name='Calibri', size=12, bold=True)
|
||||
body_font = Font(name='Calibri', size=12)
|
||||
header_fill = PatternFill(start_color="cccccc", end_color="cccccc", fill_type="solid")
|
||||
body_fill = PatternFill(start_color="eeeeee", end_color="eeeeee", fill_type="solid")
|
||||
thin_side = Side(border_style='thin', color='000000')
|
||||
border = Border(bottom=thin_side)
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
|
||||
# header row
|
||||
ws.append(['style number', 'product name', 'season', 'color', 'category', 'size range'])
|
||||
|
||||
# style the header row
|
||||
ws.column_dimensions['A'].width = 15
|
||||
ws.column_dimensions['B'].width = 30
|
||||
ws.column_dimensions['C'].width = 10
|
||||
ws.column_dimensions['D'].width = 30
|
||||
ws.column_dimensions['E'].width = 15
|
||||
ws.column_dimensions['F'].width = 35
|
||||
|
||||
for f in ('A1', 'B1', 'C1', 'D1', 'E1', 'F1'):
|
||||
ws[f].font = header_font
|
||||
ws[f].fill = header_fill
|
||||
ws[f].border = border
|
||||
|
||||
# TODO: sort matches
|
||||
|
||||
seen = {}
|
||||
|
||||
for m in matches:
|
||||
# in the case of kids,
|
||||
# we might have multiple products in a match
|
||||
seasons = m['season'].lower().split('\n')
|
||||
genders = m['gender'].lower().split('\n')
|
||||
names = m['name'].lower().split('\n')
|
||||
materials = m['material'].lower().split('\n')
|
||||
colors = m['color'].lower().split('\n')
|
||||
sizes = m['size'].lower().split('\n')
|
||||
categories = m['category'].lower().split('\n')
|
||||
|
||||
for s, g, n, m, c, sz, ct in zip_longest(seasons, genders, names, materials, colors, sizes, categories, fillvalue=''):
|
||||
if not m in seen:
|
||||
ws.append([m, format_name(n, g), format_season(s), c, ct, sz])
|
||||
seen[m] = True
|
||||
|
||||
# style body
|
||||
for row in ws.iter_rows(min_row=2, max_row=None, max_col=None):
|
||||
for cell in row:
|
||||
cell.font = body_font
|
||||
cell.fill = body_fill
|
||||
cell.border = border
|
||||
|
||||
# save
|
||||
ensure_dir(workdir)
|
||||
path = os.path.join(workdir, f"{file_base}.xlsx")
|
||||
wb.save(path)
|
||||
set_file_perms(path)
|
||||
|
||||
return path
|
||||
105
markup/tasks.py
Normal file
105
markup/tasks.py
Normal file
@ -0,0 +1,105 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
from celery import task, shared_task
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import datetime
|
||||
import fileinput
|
||||
import smtplib
|
||||
from pathlib import Path
|
||||
|
||||
from email.feedparser import FeedParser
|
||||
from email.message import EmailMessage
|
||||
from email.header import decode_header, make_header
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'procat2.settings')
|
||||
django.setup()
|
||||
|
||||
from .utils import clean_path, ensure_dir, set_file_perms, WORKDIR
|
||||
from .email import reply, reply_missing, reply_no_matches, send_error_email
|
||||
from .matching import find_marked_products
|
||||
from .spreadsheet import write_spreadsheet
|
||||
from procat2.settings import TREE_NAME
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def on_fail_handler(self, exc, task_id, args, kwargs, einfo):
|
||||
"""Send an email if a task throws an exception."""
|
||||
print(str(einfo))
|
||||
send_error_email(f'ERROR: {TREE_NAME} celery task {task_id}', str(einfo))
|
||||
|
||||
|
||||
# @shared_task(on_failure=on_fail_handler)
|
||||
# def test_fail(x, y):
|
||||
# test_fail_internal()
|
||||
|
||||
# def test_fail_internal():
|
||||
# raise KeyError()
|
||||
|
||||
|
||||
@shared_task(on_failure=on_fail_handler)
|
||||
def process_message(path):
|
||||
parser = FeedParser()
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
parser.feed(line)
|
||||
msg = parser.close()
|
||||
|
||||
frm = str(make_header(decode_header(msg['From'])))
|
||||
subject = str(make_header(decode_header(msg['Subject'])))
|
||||
|
||||
found_pdf = False
|
||||
for attach in msg.walk():
|
||||
if attach.get_content_type() == 'application/pdf':
|
||||
process_attachment(frm, subject, attach)
|
||||
found_pdf = True
|
||||
|
||||
if not found_pdf:
|
||||
reply_missing(frm, subject)
|
||||
|
||||
|
||||
def process_attachment(from_address, subject, attachment):
|
||||
# write out pdf
|
||||
pdf_name = attachment.get_filename()
|
||||
pdf_name = str(make_header(decode_header(pdf_name)))
|
||||
|
||||
# if pdf name is in UUID format, use email subject
|
||||
if re.match(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.pdf', pdf_name):
|
||||
pdf_name = f'{subject}.pdf'
|
||||
|
||||
print(f'Using pdf name: {pdf_name}')
|
||||
|
||||
pdf_base = Path(pdf_name).stem
|
||||
|
||||
workdir = os.path.join(WORKDIR, clean_path(from_address), pdf_base)
|
||||
ensure_dir(workdir)
|
||||
pdf_path = os.path.join(workdir, pdf_name)
|
||||
print(f'saving pdf to {pdf_path}')
|
||||
with open(pdf_path, 'wb') as att:
|
||||
att.write(attachment.get_payload(decode=True))
|
||||
set_file_perms(pdf_path)
|
||||
|
||||
# find matches
|
||||
matches = find_marked_products(pdf_path, workdir, debug=0)
|
||||
if not matches:
|
||||
print('no product matches')
|
||||
reply_no_matches(from_address, subject)
|
||||
return
|
||||
|
||||
print(f'{len(matches)} product matches')
|
||||
|
||||
# write spreadsheet
|
||||
xls_path = write_spreadsheet(matches, workdir, pdf_base)
|
||||
|
||||
if xls_path:
|
||||
# send reply
|
||||
print(f'wrote spreadsheet: {xls_path}')
|
||||
reply(from_address, subject, xls_path, pdf_path)
|
||||
else:
|
||||
# send error
|
||||
print(f'error creating spreadsheet')
|
||||
8
markup/urls.py
Normal file
8
markup/urls.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('submit', views.submit, name='markup_submit'),
|
||||
#path('fail', views.fail, name='markup_fail'),
|
||||
]
|
||||
94
markup/utils.py
Normal file
94
markup/utils.py
Normal file
@ -0,0 +1,94 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
WORKDIR = os.path.join(settings.ASSET_DIR, 'markup', 'work')
|
||||
|
||||
|
||||
def pdf_rect(rect, container_height):
|
||||
x1 = min(rect[0], rect[2])
|
||||
y1 = max(rect[1], rect[3])
|
||||
x2 = max(rect[0], rect[2])
|
||||
y2 = min(rect[1], rect[3])
|
||||
# and convert from pdf to image coords
|
||||
return Rect(x1, container_height - y1, x2, container_height - y2)
|
||||
|
||||
|
||||
def cv2_rect(l, t, w, h):
|
||||
return Rect(l, t, l + w, t + h)
|
||||
|
||||
|
||||
def overlaps(r1, r2, threshold):
|
||||
A = r1.to_dict()
|
||||
B = r2.to_dict()
|
||||
|
||||
# https://stackoverflow.com/questions/9324339/how-much-do-two-rectangles-overlap
|
||||
SA = A['w'] * A['h']
|
||||
SB = B['w'] * B['h']
|
||||
SI = max([0, 1 + min([A['x2'], B['x2']]) - max([A['x1'], B['x1']])]) * max([0, 1 + min([A['y2'], B['y2']]) - max([A['y1'], B['y1']])])
|
||||
SU = SA + SB - SI
|
||||
overlap = float(SI) / float(SU)
|
||||
|
||||
#print('overlap: {}%'.format(int(overlap * 100)))
|
||||
return overlap > threshold
|
||||
|
||||
|
||||
class Rect(object):
|
||||
|
||||
def __init__(self, l, t, r, b):
|
||||
self.left = l
|
||||
self.top = t
|
||||
self.right = r
|
||||
self.bottom = b
|
||||
|
||||
def translate(self, x, y):
|
||||
self.left += x
|
||||
self.top += y
|
||||
self.right += x
|
||||
self.bottom += y
|
||||
return self
|
||||
|
||||
def scale(self, x, y):
|
||||
self.left *= x
|
||||
self.top *= y
|
||||
self.right *= x
|
||||
self.bottom *= y
|
||||
return self
|
||||
|
||||
def p1(self, page_height):
|
||||
return (self.left, self.top)
|
||||
|
||||
def p2(self, page_height):
|
||||
return (self.right, self.bottom)
|
||||
|
||||
def to_dict(self):
|
||||
return {'x1': self.left,
|
||||
'y1': self.top,
|
||||
'x2': self.right,
|
||||
'y2': self.bottom,
|
||||
'w': self.right - self.left,
|
||||
'h': self.bottom - self.top }
|
||||
|
||||
def __repr__(self):
|
||||
return 'Rect[l={}, t={}, r={}, b={}]'.format(int(self.left), int(self.top), int(self.right), int(self.bottom))
|
||||
|
||||
|
||||
def ensure_dir(dir):
|
||||
if not os.path.exists(dir):
|
||||
os.makedirs(dir)
|
||||
os.chmod(dir, 0o775)
|
||||
shutil.chown(dir, group='procat')
|
||||
|
||||
|
||||
def set_file_perms(file):
|
||||
os.chmod(file, 0o664)
|
||||
shutil.chown(file, group='procat')
|
||||
|
||||
|
||||
def clean_path(path):
|
||||
"""Replace filesystem-hostile characters"""
|
||||
path = re.sub(r'[<>]', '', path)
|
||||
path = re.sub(r'[^\w@]', '_', path)
|
||||
return path
|
||||
51
markup/views.py
Normal file
51
markup/views.py
Normal file
@ -0,0 +1,51 @@
|
||||
import os
|
||||
import logging
|
||||
import humanize
|
||||
from tempfile import mkstemp
|
||||
from shutil import copyfile
|
||||
|
||||
from django.core import serializers
|
||||
from django.http import HttpResponseRedirect, HttpResponse, JsonResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.core.files.uploadhandler import TemporaryFileUploadHandler
|
||||
|
||||
from .tasks import process_message
|
||||
#from .tasks import test_fail
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
# @csrf_exempt
|
||||
# def fail(request):
|
||||
# test_fail.delay(1, 2)
|
||||
# return JsonResponse({'success': True}, safe=False)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["POST"])
|
||||
def submit(request):
|
||||
# always upload into a file
|
||||
request.upload_handlers = [TemporaryFileUploadHandler(request)]
|
||||
|
||||
body = request.body
|
||||
if not body or len(body) < 1:
|
||||
return HttpResponse('Bad request: no data', status=400)
|
||||
|
||||
msg_file = request.FILES['file']
|
||||
if not msg_file:
|
||||
return HttpResponse('Bad request: no file', status=400)
|
||||
|
||||
msg_size = humanize.naturalsize(msg_file.size, gnu=True)
|
||||
log.debug('message file size: {}'.format(msg_size))
|
||||
|
||||
_, tmpfile = mkstemp(suffix='.eml', prefix='markup_', dir=None, text=False)
|
||||
log.debug('copy message file from {} to {}'.format(msg_file.temporary_file_path(), tmpfile))
|
||||
copyfile(msg_file.temporary_file_path(), tmpfile)
|
||||
os.chmod(tmpfile, 0o666)
|
||||
|
||||
process_message.delay(tmpfile)
|
||||
|
||||
return JsonResponse({'success': True}, safe=False)
|
||||
0
markup/work/__init__.py
Normal file
0
markup/work/__init__.py
Normal file
51
markup/work/test_all.py
Executable file
51
markup/work/test_all.py
Executable file
@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
parentdir = os.path.dirname(currentdir)
|
||||
parentparentdir = os.path.dirname(parentdir)
|
||||
sys.path.insert(0, parentparentdir)
|
||||
|
||||
import dumper
|
||||
import getopt
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'procat2.settings')
|
||||
django.setup()
|
||||
|
||||
from markup.utils import WORKDIR, clean_path
|
||||
from markup.matching import find_marked_products
|
||||
from markup.spreadsheet import write_spreadsheet
|
||||
|
||||
|
||||
def main(argv):
|
||||
def usage():
|
||||
print('usage: %s -s subdir [-d] file.pdf' % argv[0])
|
||||
return 100
|
||||
try:
|
||||
(opts, args) = getopt.getopt(argv[1:], 'd')
|
||||
except getopt.GetoptError:
|
||||
return usage()
|
||||
if not args: return usage()
|
||||
debug = 0
|
||||
subdir = 'test'
|
||||
for (k, v) in opts:
|
||||
if k == '-d': debug += 1
|
||||
elif k == '-s': subdir = v
|
||||
|
||||
fname = args[0]
|
||||
path = Path(fname)
|
||||
workdir = os.path.join(WORKDIR, 'test', clean_path(path.stem))
|
||||
|
||||
matches = find_marked_products(fname, workdir, debug)
|
||||
print(f'{len(matches)} product matches')
|
||||
write_spreadsheet(matches, workdir, path.stem)
|
||||
|
||||
|
||||
if __name__ == '__main__': sys.exit(main(sys.argv))
|
||||
37
markup/work/test_email.py
Executable file
37
markup/work/test_email.py
Executable file
@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# process an .eml file through the whole markup process
|
||||
|
||||
import sys
|
||||
import os
|
||||
import inspect
|
||||
|
||||
currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
parentdir = os.path.dirname(currentdir)
|
||||
parentparentdir = os.path.dirname(parentdir)
|
||||
sys.path.insert(0, parentparentdir)
|
||||
|
||||
import getopt
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'procat2.settings')
|
||||
django.setup()
|
||||
|
||||
from markup.tasks import process_message
|
||||
|
||||
|
||||
def main(argv):
|
||||
def usage():
|
||||
print('usage: %s file.eml' % argv[0])
|
||||
return 100
|
||||
try:
|
||||
(opts, args) = getopt.getopt(argv[1:], '')
|
||||
except getopt.GetoptError:
|
||||
return usage()
|
||||
if not args: return usage()
|
||||
|
||||
process_message(args[0])
|
||||
|
||||
|
||||
if __name__ == '__main__': sys.exit(main(sys.argv))
|
||||
38
markup/work/test_pdf.py
Executable file
38
markup/work/test_pdf.py
Executable file
@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import os
|
||||
import inspect
|
||||
|
||||
currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
parentdir = os.path.dirname(currentdir)
|
||||
parentparentdir = os.path.dirname(parentdir)
|
||||
sys.path.insert(0, parentparentdir)
|
||||
|
||||
import getopt
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'procat2.settings')
|
||||
django.setup()
|
||||
|
||||
from markup.pdf import parse_pdf
|
||||
|
||||
|
||||
def main(argv):
|
||||
def usage():
|
||||
print('usage: %s [-d] file ...' % argv[0])
|
||||
return 100
|
||||
try:
|
||||
(opts, args) = getopt.getopt(argv[1:], 'd')
|
||||
except getopt.GetoptError:
|
||||
return usage()
|
||||
if not args: return usage()
|
||||
debug = 0
|
||||
for (k, v) in opts:
|
||||
if k == '-d': debug += 1
|
||||
|
||||
(prods, scribbles) = parse_pdf(args[0], debug)
|
||||
print('prods', scribbles)
|
||||
|
||||
|
||||
if __name__ == '__main__': sys.exit(main(sys.argv))
|
||||
39
markup/work/test_scribble.py
Executable file
39
markup/work/test_scribble.py
Executable file
@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import os
|
||||
import inspect
|
||||
|
||||
currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
parentdir = os.path.dirname(currentdir)
|
||||
parentparentdir = os.path.dirname(parentdir)
|
||||
sys.path.insert(0, parentparentdir)
|
||||
|
||||
#import dumper
|
||||
import getopt
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'procat2.settings')
|
||||
django.setup()
|
||||
|
||||
from markup.img import find_shapes
|
||||
|
||||
|
||||
def main(argv):
|
||||
def usage():
|
||||
print('usage: %s [-d] file ...' % argv[0])
|
||||
return 100
|
||||
try:
|
||||
(opts, args) = getopt.getopt(argv[1:], 'd')
|
||||
except getopt.GetoptError:
|
||||
return usage()
|
||||
if not args: return usage()
|
||||
debug = 0
|
||||
for (k, v) in opts:
|
||||
if k == '-d': debug += 1
|
||||
|
||||
boxes = find_shapes(args[0])
|
||||
print(boxes)
|
||||
|
||||
|
||||
if __name__ == '__main__': sys.exit(main(sys.argv))
|
||||
@ -0,0 +1,5 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
|
||||
15
procat2/celery.py
Normal file
15
procat2/celery.py
Normal file
@ -0,0 +1,15 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
import os
|
||||
from celery import Celery
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'procat2.settings')
|
||||
|
||||
app = Celery('procat2')
|
||||
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
app.autodiscover_tasks()
|
||||
|
||||
# @app.task(bind=True)
|
||||
# def debug_task(self):
|
||||
# print('Request: {0!r}'.format(self.request))
|
||||
33
procat2/management/commands/add_user.py
Normal file
33
procat2/management/commands/add_user.py
Normal file
@ -0,0 +1,33 @@
|
||||
import re
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Add a procat2 user'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('first_name', type=str)
|
||||
parser.add_argument('last_name', type=str)
|
||||
parser.add_argument('email', type=str)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
fname = options['first_name']
|
||||
lname = options['last_name']
|
||||
email = options['email']
|
||||
email = re.sub('[<>]', '', email)
|
||||
|
||||
uname = str(lname[:5] + fname[:3]).lower()
|
||||
pw = str(fname[0] + lname[0] + 'visual').lower()
|
||||
|
||||
user = User.objects.create_user(uname, password=pw)
|
||||
user.first_name = fname
|
||||
user.last_name = lname
|
||||
user.email = email
|
||||
user.is_superuser = False
|
||||
user.is_staff = False
|
||||
user.is_active = True
|
||||
user.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'Added user "{uname}" with password "{pw}" ({email})'))
|
||||
19
procat2/management/commands/cat_ids.py
Normal file
19
procat2/management/commands/cat_ids.py
Normal file
@ -0,0 +1,19 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from procat2.models import Catalog
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Return material numbers for a catalog'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('cat_ids', nargs='+', type=int)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
for cat_id in options['cat_ids']:
|
||||
try:
|
||||
cat = Catalog.objects.get(pk=cat_id)
|
||||
except Catalog.DoesNotExist:
|
||||
raise CommandError('Catalog "%s" does not exist' % cat_id)
|
||||
|
||||
for id in cat.product_ids():
|
||||
self.stdout.write(id)
|
||||
18
procat2/migrations/0007_region_visible.py
Normal file
18
procat2/migrations/0007_region_visible.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.4 on 2019-11-01 23:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('procat2', '0006_catalog_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='region',
|
||||
name='visible',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@ -30,12 +30,14 @@ class Region(models.Model):
|
||||
id = models.CharField(max_length=30, primary_key=True)
|
||||
name = models.CharField(max_length=100)
|
||||
ordering = models.PositiveIntegerField(unique=True, default=1000)
|
||||
visible = models.BooleanField(default=True)
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'ordering': self.ordering,
|
||||
'visible': self.visible
|
||||
}
|
||||
|
||||
|
||||
@ -58,7 +60,7 @@ class Catalog(models.Model):
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
updated = models.DateTimeField(auto_now=True, db_index=True)
|
||||
name = models.CharField(max_length=300)
|
||||
email = models.CharField(max_length=300, null=True)
|
||||
email = models.CharField(max_length=300, null=True, blank=True)
|
||||
public = models.BooleanField(default=False, db_index=True)
|
||||
pages = models.PositiveIntegerField(default=0)
|
||||
sections = models.PositiveIntegerField(default=0)
|
||||
|
||||
@ -9,8 +9,15 @@ https://docs.djangoproject.com/en/2.1/topics/settings/
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/2.1/ref/settings/
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import os
|
||||
|
||||
# ^^^ The above is required if you want to import from the celery
|
||||
# library. If you don't have this then `from celery.schedules import`
|
||||
# becomes `proj.celery.schedules` in Python 2.x since it allows
|
||||
# for relative imports by default.
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
@ -77,6 +84,12 @@ LOGGING = {
|
||||
#'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
|
||||
'propagate': True,
|
||||
},
|
||||
'markup': {
|
||||
'handlers': ['console', 'file'],
|
||||
'level': 'DEBUG',
|
||||
#'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
|
||||
'propagate': True,
|
||||
},
|
||||
'django': {
|
||||
'handlers': ['console', 'file'],
|
||||
#'level': 'DEBUG',
|
||||
@ -101,6 +114,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.sites',
|
||||
'django_extensions',
|
||||
'django_celery_results',
|
||||
'account',
|
||||
'lazysignup',
|
||||
'webpack_loader',
|
||||
@ -108,6 +122,7 @@ INSTALLED_APPS = [
|
||||
'dashboard',
|
||||
'products',
|
||||
'quickinfo',
|
||||
'markup',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@ -227,3 +242,13 @@ WEBPACK_LOADER = {
|
||||
'IGNORE': [r'.+\.hot-update.js', r'.+\.map']
|
||||
}
|
||||
}
|
||||
|
||||
# allow large file uploads
|
||||
# for example the markup tool receiving emails
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = FILE_UPLOAD_MAX_MEMORY_SIZE = 200 * 1024 * 1024 # 200MB
|
||||
|
||||
# celery settings
|
||||
CELERY_BROKER_URL = 'amqp://guest:guest@localhost//'
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_RESULT_BACKEND = 'django-db'
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
|
||||
@ -50,6 +50,8 @@ urlpatterns = [
|
||||
path('convert/done/', lazy_convert_done, name='lazysignup_convert_done'),
|
||||
|
||||
path('quickinfo/', include('quickinfo.urls')),
|
||||
|
||||
path('markup/', include('markup.urls')),
|
||||
]
|
||||
|
||||
if settings.DJDT:
|
||||
|
||||
@ -1,20 +1,37 @@
|
||||
amqp==2.5.1
|
||||
backcall==0.1.0
|
||||
billiard==3.6.1.0
|
||||
celery==4.3.0
|
||||
decorator==4.4.0
|
||||
Django==2.2.4
|
||||
django-appconf==1.0.3
|
||||
django-celery-results==1.1.2
|
||||
django-debug-toolbar==1.11
|
||||
django-extensions==2.1.6
|
||||
django-lazysignup==2.0.0
|
||||
django-settings-export==1.2.1
|
||||
django-user-accounts==2.1.0
|
||||
django-webpack-loader==0.6.0
|
||||
Dumper==1.2.0
|
||||
et-xmlfile==1.0.1
|
||||
humanize==0.5.1
|
||||
importlib-metadata==0.23
|
||||
imutils==0.5.3
|
||||
ipdb==0.11
|
||||
ipython==7.3.0
|
||||
ipython-genutils==0.2.0
|
||||
jdcal==1.4.1
|
||||
jedi==0.13.3
|
||||
kombu==4.6.5
|
||||
more-itertools==7.2.0
|
||||
numpy==1.17.2
|
||||
opencv-python==4.1.1.26
|
||||
openpyxl==3.0.0
|
||||
parso==0.3.4
|
||||
pdfminer==20191010
|
||||
pexpect==4.6.0
|
||||
pickleshare==0.7.5
|
||||
Pillow==6.2.0
|
||||
prompt-toolkit==2.0.9
|
||||
psycopg2-binary==2.7.7
|
||||
ptyprocess==0.6.0
|
||||
@ -23,5 +40,7 @@ pytz==2018.9
|
||||
six==1.12.0
|
||||
sqlparse==0.3.0
|
||||
traitlets==4.3.2
|
||||
vine==1.3.0
|
||||
wcwidth==0.1.7
|
||||
Werkzeug==0.14.1
|
||||
zipp==0.6.0
|
||||
|
||||
@ -7,6 +7,13 @@
|
||||
<div class="uk-section">
|
||||
<div class="uk-container">
|
||||
|
||||
<div class="uk-flex uk-flex-center">
|
||||
<div class="uk-alert-primary" uk-alert>
|
||||
<a class="uk-alert-close" uk-close></a>
|
||||
<p style="padding-right: 10px">Watch the <a href="https://keenfootwear.wistia.com/medias/ld970jfi9o" target="_blank">tutorial video on creating catalogs!</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-grid-match uk-child-width-expand" uk-grid>
|
||||
|
||||
<div>
|
||||
@ -23,10 +30,10 @@
|
||||
<div class="uk-card uk-card-default uk-card-body">
|
||||
<h3 class="uk-card-title">{% trans "Images" %}</h3>
|
||||
<ul class="uk-list">
|
||||
<li><a href="http://keen.apparentinc.com/tools/downloader/">{% trans "Image downloader" %}</a></li>
|
||||
<li><a href="http://a.keen.procatalog.io/tools/downloader/">{% trans "Image downloader" %}</a></li>
|
||||
{% if not user|is_lazy_user %}
|
||||
<li><a href="http://keen.apparentinc.com/images/upload">{% trans "Image uploader" %}</a></li>
|
||||
<li><a href="http://keen.apparentinc.com/images/">{% trans "Image manager" %}</a></li>
|
||||
<li><a href="http://a.keen.procatalog.io/images/upload">{% trans "Image uploader" %}</a></li>
|
||||
<li><a href="http://a.keen.procatalog.io/tools/status">{% trans "Image status" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
@ -37,7 +44,7 @@
|
||||
<div class="uk-card uk-card-default uk-card-body">
|
||||
<h3 class="uk-card-title">{% trans "Tools" %}</h3>
|
||||
<ul class="uk-list">
|
||||
<li><a href="http://keen.apparentinc.com/tools/regionizer/">{% trans "Region editor" %}</a></li>
|
||||
<li><a href="http://a.keen.procatalog.io/tools/regionizer/">{% trans "Region editor" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -30,10 +30,10 @@
|
||||
<a>{% trans "Images" %}</a>
|
||||
<div class="uk-navbar-dropdown">
|
||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||
<li><a href="http://keen.apparentinc.com/tools/downloader/">Image downloader</a></li>
|
||||
<li><a href="http://a.keen.procatalog.io/tools/downloader/">Image downloader</a></li>
|
||||
{% if not user|is_lazy_user %}
|
||||
<li><a href="http://keen.apparentinc.com/images/upload">Image uploader</a></li>
|
||||
<li><a href="http://keen.apparentinc.com/images/">Image manager</a></li>
|
||||
<li><a href="http://a.keen.procatalog.io/images/upload">Image uploader</a></li>
|
||||
<li><a href="http://a.keen.procatalog.io/tools/status">Image status</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
@ -44,7 +44,7 @@
|
||||
<a>{% trans "Tools" %}</a>
|
||||
<div class="uk-navbar-dropdown">
|
||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||
<li><a href="http://keen.apparentinc.com/tools/regionizer/">Region editor</a></li>
|
||||
<li><a href="http://a.keen.procatalog.io/tools/regionizer/">Region editor</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
@ -63,6 +63,7 @@
|
||||
<div class="uk-navbar-dropdown">
|
||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||
<li><a href="mailto:support@procatalog.io?Subject=Keen%20ProCatalog%20support%20request">Email support</a></li>
|
||||
<li><a href="https://keenfootwear.wistia.com/medias/ld970jfi9o" target="_blank">Catalog creation tutorial</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user