From e99eb5a68ab2e7fb923629a10f6223933cbbf868 Mon Sep 17 00:00:00 2001 From: msinkec Date: Wed, 10 Mar 2021 18:55:24 +0100 Subject: [PATCH] Code refactored to be ready for solar upgrade. Many other minor fixes. --- app.py | 310 ++++++++---------------------------- config.ini | 11 +- contract/out.pdf | Bin 28387 -> 0 bytes contract/pdf.py | 43 ----- contract/template.html | 22 +-- portal/base.py | 250 +++++++++++++++++++++++++++++ static/image/logo.svg | 3 + static/style.css | 29 +++- templates/basic.html | 300 +++++++++++++++++++++++++++++++++++ templates/index.html | 348 +---------------------------------------- 10 files changed, 655 insertions(+), 661 deletions(-) delete mode 100644 contract/out.pdf delete mode 100644 contract/pdf.py create mode 100644 portal/base.py create mode 100644 static/image/logo.svg create mode 100644 templates/basic.html diff --git a/app.py b/app.py index e60ff93..1dd90a8 100644 --- a/app.py +++ b/app.py @@ -1,57 +1,14 @@ import os -import re -import hashlib -import time -import ssl import configparser from pathlib import Path from flask import Flask, render_template, request from flask_dropzone import Dropzone -import imaplib -from smtplib import SMTP_SSL +import portal.base -import email -from email import encoders -from email.mime.base import MIMEBase -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from email.mime.application import MIMEApplication +# TODO: Put all the stuff in base.py into a class, so it can have a state of it's own, to avoid passing a bunch of arguments at each function call. -import pdfkit -from jinja2 import Environment, FileSystemLoader - - -class ContractCreator: - - def __init__(self): - template_loader = FileSystemLoader(searchpath="./") - template_env = Environment(loader=template_loader) - self.template = template_env.get_template('contract/template.html') - - self.pdfkit_options = { - 'page-size': 'A4', - 'margin-top': '0.75in', - 'margin-right': '0.75in', - 'margin-bottom': '0.75in', - 'margin-left': '0.75in', - 'encoding': "UTF-8", - 'custom-header' : [ - ('Accept-Encoding', 'gzip') - ] - } - - def fill_template(self, **kwargs): - return self.template.render(**kwargs) - - def create_pdf(self, out_f, fields_dict): - html_str = self.fill_template(**fields_dict) - pdfkit.from_string(html_str, out_f, options=self.pdfkit_options) - - -ENABLED_FILETYPES = ['txt', 'csv', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'] -REGEX_EMAIL = re.compile('^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$') ###################### # Load configuration # @@ -67,10 +24,22 @@ SMTP_PORT = int(config['SMTP_PORT']) IMAP_PORT = int(config['IMAP_PORT']) MAX_UPLOAD_SIZE = int(config['MAX_UPLOAD_SIZE']) # Bytes CONTRACT_CLIENT_CONTACT = config['CONTRACT_CLIENT_CONTACT'] -if 'BASE_DIR' in config: - BASE_DIR = Path(config['BASE_DIR']) +MAIL_SUBJECT = config['MAIL_SUBJECT'] +MAIL_BODY = config['MAIL_BODY'] + +if 'UPLOADS_DIR' in config: + UPLOADS_DIR = Path(config['UPLOADS_DIR']) +else: + UPLOADS_DIR = Path(__file__).resolve().parent / 'uploads' +if not UPLOADS_DIR.exists: + UPLOADS_DIR.mkdir(parents=True) + +if 'DATA_DIR' in config: + DATA_DIR = Path(config['DATA_DIR']) else: - BASE_DIR = Path(__file__).resolve().parent + DATA_DIR = Path(__file__).resolve().parent / 'data' +if not DATA_DIR.exists: + DATA_DIR.mkdir(parents=True) # Override configs with environment variables, if set if 'PORTALDS4DS1_MAIL_HOST' in os.environ: @@ -87,243 +56,86 @@ if 'PORTALDS4DS1_MAX_UPLOAD_SIZE' in os.environ: MAX_UPLOAD_SIZE = int(os.environ['PORTALDS4DS1_MAX_UPLOAD_SIZE']) if 'PORTALDS4DS1_CONTRACT_CLIENT_CONTACT' in os.environ: CONTRACT_CLIENT_CONTACT = os.environ['PORTALDS4DS1_CONTRACT_CLIENT_CONTACT'] +if 'PORTALDS4DS1_UPLOADS_DIR' in os.environ: + UPLOADS_DIR = os.environ['PORTALDS4DS1_UPLOADS_DIR'] +if 'PORTALDS4DS1_DATA_DIR' in os.environ: + DATA_DIR = os.environ['PORTALDS4DS1_DATA_DIR'] +if 'PORTALDS4DS1_MAIL_SUBJECT' in os.environ: + MAIL_SUBJECT = os.environ['PORTALDS4DS1_MAIL_SUBJECT'] +if 'PORTALDS4DS1_MAIL_BODY' in os.environ: + MAIL_BODY = os.environ['PORTALDS4DS1_MAIL_BODY'] + +VALID_CORPUS_NAMES = ['prevodi', 'gigafida', 'solar'] -UPLOAD_DIR = BASE_DIR / 'uploads' -if not UPLOAD_DIR.exists: - UPLOAD_DIR.mkdir(parents=True) ###################### app = Flask(__name__) app.config.update( - UPLOADED_PATH = UPLOAD_DIR, + UPLOADED_PATH = UPLOADS_DIR, MAX_CONTENT_LENGTH = MAX_UPLOAD_SIZE, TEMPLATES_AUTO_RELOAD = True ) dropzone = Dropzone(app) -contract_creator = ContractCreator() - @app.route('/') def index(): return render_template('index.html') -@app.route('/upload', methods=['POST']) -def handle_upload(): +@app.route('/') +def index_corpus(corpus_name): + if corpus_name not in VALID_CORPUS_NAMES: + return 'Korpus "{}" ne obstaja.'.format(corpus_name), 404 + if corpus_name == 'prevodi': + subtitle = 'KORPUS PARALELNIH BESEDIL ANG-SLO' + elif corpus_name == 'gigafida': + subtitle = 'KORPUS GIGAFIDA' + return render_template('basic.html', subtitle=subtitle, corpus_name=corpus_name) + + +@app.route('//upload', methods=['POST']) +def handle_upload(corpus_name): + if corpus_name not in VALID_CORPUS_NAMES: + return 404 + files = request.files if len(files) > 20: return 'Naložite lahko do 20 datotek hkrati.', 400 elif len(files) < 1: return 'Priložena ni bila nobena datoteka.', 400 - err = check_suffixes(files) + print('one') + err = portal.base.check_suffixes(files) if err: return err, 400 - err = check_form(request.form) + print('two') + err = portal.base.check_form(request.form) if err: return err, 400 - upload_metadata = get_upload_metadata(request) - contract_file_name = generate_contract_pdf(upload_metadata) + print('three') + upload_metadata = portal.base.get_upload_metadata(corpus_name, request) + contract_file_name = portal.base.generate_contract_pdf(UPLOADS_DIR, upload_metadata, CONTRACT_CLIENT_CONTACT) + # Add contract_file_name to metadata TODO: move somewhere else upload_metadata['contract'] = contract_file_name - store_datafiles(files, upload_metadata) - store_metadata(upload_metadata) - send_confirm_mail(upload_metadata) + portal.base.store_datafiles(UPLOADS_DIR, files, upload_metadata) + portal.base.store_metadata(UPLOADS_DIR, upload_metadata) + portal.base.send_confirm_mail( + subject=MAIL_SUBJECT, + body=MAIL_BODY, + uploads_path=UPLOADS_DIR, + upload_metadata=upload_metadata, + mail_host=MAIL_HOST, mail_login=MAIL_LOGIN, mail_pass=MAIL_PASS, + imap_port=IMAP_PORT, smtp_port=SMTP_PORT) return 'Uspešno ste oddali datotek(e). Št. datotek: {}'.format(len(files)) -def get_upload_metadata(request): - upload_metadata = dict() - - file_hashes = create_file_hashes(request.files) - form_data = request.form.copy() - upload_timestamp = int(time.time()) - upload_id = create_upload_id(form_data, upload_timestamp, file_hashes) - - upload_metadata['form_data'] = form_data - upload_metadata['upload_id'] = upload_id - upload_metadata['timestamp'] = upload_timestamp - upload_metadata['file_hashes'] = file_hashes - - return upload_metadata - -def check_suffixes(files): - for key, f in files.items(): - if key.startswith('file'): - suffix = f.filename.split('.')[-1] - if suffix not in ENABLED_FILETYPES: - return 'Datoteka "{}" ni pravilnega formata.'.format(f.filename) - return None - - -def get_subdir(dir_name): - subdir = app.config['UPLOADED_PATH'] / dir_name - if not subdir.exists(): - subdir.mkdir() - return subdir - - -def create_upload_id(form_data, upload_timestamp, file_hashes): - tip = form_data.get('tip') - ime = form_data.get('ime') - podjetje = form_data.get('podjetje') - naslov = form_data.get('naslov') - posta = form_data.get('posta') - email = form_data.get('email') - telefon = form_data.get('telefon') - - # This hash serves as an unique identifier for the whole upload. - metahash = hashlib.md5((tip+ime+podjetje+naslov+posta+email+telefon).encode()) - # Include file hashes to avoid metafile name collisions if they have the same form values, - # but different data files. Sort hashes first so upload order doesn't matter. - sorted_f_hashes = list(file_hashes.values()) - sorted_f_hashes.sort() - metahash.update(''.join(sorted_f_hashes).encode()) - metahash = metahash.hexdigest() - - return metahash - - -def check_form(form): - tip = form.get('tip') - ime = form.get('ime') - podjetje = form.get('podjetje') - naslov = form.get('naslov') - posta = form.get('posta') - email = form.get('email') - telefon = form.get('telefon') - - if tip not in ['enojez', 'prevodi']: - return 'Napačen tip besedila.' - - if len(ime) > 100: - return 'Predolgo ime.' - - if len(podjetje) > 100: - return 'Predolgo ime institucije.' - - if len(email) > 100: - return 'Predolgi email naslov' - elif not re.search(REGEX_EMAIL, email): - return 'Email napačnega formata.' - - if len(telefon) > 100: - return 'Predolga telefonska št.' - - if len(naslov) > 100: - return 'Predolg naslov.' - - if len(posta) > 100: - return 'Predolga pošta' - - return None - - -def create_file_hashes(files): - res = dict() - for key, f in files.items(): - if key.startswith('file'): - h = hashlib.md5(f.filename.encode()) - h.update(f.stream.read()) - res[key] = h.hexdigest() - f.seek(0) - return res - - -def store_metadata(upload_metadata): - base = get_subdir('meta') - - timestamp = upload_metadata['timestamp'] - upload_id = upload_metadata['upload_id'] - form_data = upload_metadata['form_data'] - email = form_data['email'] - file_hashes = upload_metadata['file_hashes'] - contract = upload_metadata['contract'] - filename = str(timestamp) + '-' + email + '-' + upload_id + '.meta' - - sorted_f_hashes = list(file_hashes.values()) - sorted_f_hashes.sort() - - path = base / filename - with path.open('w') as f: - f.write('tip=' + form_data['tip']) - f.write('\nime=' + form_data['ime']) - f.write('\npodjetje=' + form_data['podjetje']) - f.write('\nnaslov=' + form_data['naslov']) - f.write('\nposta=' + form_data['posta']) - f.write('\nemail=' + form_data['email']) - f.write('\ndatoteke=' + str(sorted_f_hashes)) - f.write('\npogodba=' + contract) - - -def store_datafiles(files, upload_metadata): - base = get_subdir('files') - file_hashes = upload_metadata['file_hashes'] - - for key, f in files.items(): - if key.startswith('file'): - path = base / file_hashes[key] - if not path.exists(): - path.mkdir() - f.save(path / f.filename) - -def generate_contract_pdf(upload_metadata): - base = get_subdir('contracts') - contract_file_name = upload_metadata['upload_id'] + '.pdf' - form_data = upload_metadata['form_data'] - data = { - 'ime_priimek': form_data['ime'], - 'naslov': form_data['naslov'], - 'posta': form_data['posta'], - 'kontakt_narocnik': CONTRACT_CLIENT_CONTACT, - 'kontakt_imetnikpravic': form_data['ime'] - } - - contract_creator.create_pdf(base / contract_file_name, data) - return contract_file_name - -def send_confirm_mail(upload_metadata): - body = 'Usprešno ste oddali besedila. V prilogi vam pošiljamo pogodbo.' - - message = MIMEMultipart() - message['From'] = MAIL_LOGIN - message['To'] = upload_metadata['form_data']['email'] - message['Subject'] = 'Pogodba za oddana besedila ' + upload_metadata['upload_id'] - message.attach(MIMEText(body, "plain")) - - contracts_dir = get_subdir('contracts') - base_name = upload_metadata['contract'] - contract_file = contracts_dir / base_name - with open(contract_file, "rb") as f: - part = MIMEApplication( - f.read(), - Name = base_name - ) - part['Content-Disposition'] = 'attachment; filename="%s"' % base_name - message.attach(part) - - text = message.as_string() - - # Create a secure SSL context - context = ssl.create_default_context() - - with SMTP_SSL(MAIL_HOST, SMTP_PORT, context=context) as server: - server.login(MAIL_LOGIN, MAIL_PASS) - server.sendmail(message['From'], message['To'], text) - - # Save copy of sent mail in Sent mailbox - imap = imaplib.IMAP4_SSL(MAIL_HOST, IMAP_PORT) - imap.login(MAIL_LOGIN, MAIL_PASS) - imap.append('Sent', '\\Seen', imaplib.Time2Internaldate(time.time()), text.encode('utf8')) - imap.logout() - - if __name__ == '__main__': app.run(debug=True) diff --git a/config.ini b/config.ini index d340504..a8ef2e4 100644 --- a/config.ini +++ b/config.ini @@ -1,9 +1,16 @@ [DEFAULT] MAIL_HOST=posta.cjvt.si MAIL_LOGIN=oddaja-besedil@cjvt.si -MAIL_PASS=randompass123 +MAIL_PASS=secretmailpass123 SMTP_PORT=465 IMAP_PORT=993 MAX_UPLOAD_SIZE=1000000000 -BASE_DIR=./ +UPLOADS_DIR=./uploads +DATA_DIR=./data CONTRACT_CLIENT_CONTACT=Testko Tester +MAIL_SUBJECT=RSDO: pogodba za oddana besedila ({upload_id}) +MAIL_BODY=Hvala, ker ste prispevali besedila in na ta način pomagali pri razvoju slovenskega jezika v digitalnem okolju. V prilogi vam pošiljamo pogodbo s seznamom naloženih datotek. + + + Lep pozdrav, + ekipa RSDO diff --git a/contract/out.pdf b/contract/out.pdf deleted file mode 100644 index 34c3b9ab5d03f1de9ab477caf8ee825b69dd7192..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28387 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 U=cx~W3pwC(CDGAIC`e-d7wX}N`Tzg` diff --git a/contract/pdf.py b/contract/pdf.py deleted file mode 100644 index c8b55e8..0000000 --- a/contract/pdf.py +++ /dev/null @@ -1,43 +0,0 @@ -import pdfkit -from jinja2 import Environment, FileSystemLoader - - -class ContractCreator: - - def __init__(self): - template_loader = FileSystemLoader(searchpath="./") - template_env = Environment(loader=template_loader) - self.template = template_env.get_template('template.html') - - self.pdfkit_options = { - 'page-size': 'A4', - 'margin-top': '0.75in', - 'margin-right': '0.75in', - 'margin-bottom': '0.75in', - 'margin-left': '0.75in', - 'encoding': "UTF-8", - 'custom-header' : [ - ('Accept-Encoding', 'gzip') - ] - } - - def fill_template(self, **kwargs): - return self.template.render(**kwargs) - - def create_pdf(self, out_f, fields_dict): - html_str = self.fill_template(**fields_dict) - pdfkit.from_string(html_str, out_f, options=self.pdfkit_options) - - -if __name__ == '__main__': - test_data = { - 'ime_priimek': 'Testko Tester', - 'naslov': 'Testovci 10', - 'posta': '1123', - 'kontakt_narocnik': 'Testica Testkovič', - 'kontakt_imetnikpravic': 'Testko Tester', - 'date': '16.2.2021' - } - - contract_creator = ContractCreator() - contract_creator.create_pdf('out.pdf', test_data) diff --git a/contract/template.html b/contract/template.html index 1bf585e..d45eca6 100644 --- a/contract/template.html +++ b/contract/template.html @@ -175,27 +175,7 @@ Ime, naslov ali oznaka dela - -   - - -   - - -   - - -   - - -   - - -   - - -   - + {{files_table_str}} diff --git a/portal/base.py b/portal/base.py new file mode 100644 index 0000000..3af7875 --- /dev/null +++ b/portal/base.py @@ -0,0 +1,250 @@ +import re +import hashlib +import time +import ssl +from pathlib import Path + +import imaplib +from smtplib import SMTP_SSL + +import email +from email import encoders +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.application import MIMEApplication + +import pdfkit +from jinja2 import Environment, FileSystemLoader + + +ENABLED_FILETYPES = ['txt', 'csv', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'xml', 'mxliff', 'tmx'] +REGEX_EMAIL = re.compile('^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$') + + +class ContractCreator: + + def __init__(self): + template_loader = FileSystemLoader(searchpath="./") + template_env = Environment(loader=template_loader) + self.template = template_env.get_template('contract/template.html') + + self.pdfkit_options = { + 'page-size': 'A4', + 'margin-top': '0.75in', + 'margin-right': '0.75in', + 'margin-bottom': '0.75in', + 'margin-left': '0.75in', + 'encoding': "UTF-8", + 'custom-header' : [ + ('Accept-Encoding', 'gzip') + ] + } + + def fill_template(self, **kwargs): + return self.template.render(**kwargs) + + def create_pdf(self, out_f, fields_dict): + html_str = self.fill_template(**fields_dict) + pdfkit.from_string(html_str, out_f, options=self.pdfkit_options) + + +contract_creator = ContractCreator() + + +def get_upload_metadata(corpus_name, request): + upload_metadata = dict() + + file_hashes = create_file_hashes(request.files) + file_names = file_hashes.keys() + form_data = request.form.copy() + upload_timestamp = int(time.time()) + upload_id = create_upload_id(corpus_name, form_data, upload_timestamp, file_hashes) + + upload_metadata['corpus_name'] = corpus_name + upload_metadata['form_data'] = form_data + upload_metadata['upload_id'] = upload_id + upload_metadata['timestamp'] = upload_timestamp + upload_metadata['file_hashes'] = file_hashes + upload_metadata['file_names'] = file_names + + return upload_metadata + + +def check_suffixes(files): + for key, f in files.items(): + if key.startswith('file'): + suffix = f.filename.split('.')[-1] + if suffix not in ENABLED_FILETYPES: + return 'Datoteka "{}" ni pravilnega formata.'.format(f.filename) + return None + + +def get_subdir(uploads_path, dir_name): + subdir = uploads_path / dir_name + if not subdir.exists(): + subdir.mkdir(parents=True) + return subdir + + +def create_upload_id(corpus_name, form_data, upload_timestamp, file_hashes): + ime = form_data.get('ime') + podjetje = form_data.get('podjetje') + naslov = form_data.get('naslov') + posta = form_data.get('posta') + email = form_data.get('email') + telefon = form_data.get('telefon') + + # This hash serves as an unique identifier for the whole upload. + metahash = hashlib.md5((corpus_name+ime+podjetje+naslov+posta+email+telefon).encode()) + # Include file hashes to avoid metafile name collisions if they have the same form values, + # but different data files. Sort hashes first so upload order doesn't matter. + sorted_f_hashes = list(file_hashes.values()) + sorted_f_hashes.sort() + metahash.update(''.join(sorted_f_hashes).encode()) + metahash = metahash.hexdigest() + + return metahash + + +def check_form(form): + ime = form.get('ime') + podjetje = form.get('podjetje') + naslov = form.get('naslov') + posta = form.get('posta') + email = form.get('email') + telefon = form.get('telefon') + + if len(ime) > 100: + return 'Predolgo ime.' + + if len(podjetje) > 100: + return 'Predolgo ime institucije.' + + if len(email) > 100: + return 'Predolgi email naslov' + elif not re.search(REGEX_EMAIL, email): + return 'Email napačnega formata.' + + if len(telefon) > 100: + return 'Predolga telefonska št.' + + if len(naslov) > 100: + return 'Predolg naslov.' + + if len(posta) > 100: + return 'Predolga pošta' + + return None + + +def create_file_hashes(files): + res = dict() + for key, f in files.items(): + if key.startswith('file'): + h = hashlib.md5(f.filename.encode()) + h.update(f.stream.read()) + res[f.filename] = h.hexdigest() + f.seek(0) + return res + + +def store_metadata(uploads_path, upload_metadata): + base = get_subdir(uploads_path, 'meta') + + timestamp = upload_metadata['timestamp'] + upload_id = upload_metadata['upload_id'] + form_data = upload_metadata['form_data'] + email = form_data['email'] + file_hashes = upload_metadata['file_hashes'] + contract = upload_metadata['contract'] + filename = str(timestamp) + '-' + email + '-' + upload_id + '.meta' + + sorted_f_hashes = list(file_hashes.values()) + sorted_f_hashes.sort() + + path = base / filename + with path.open('w') as f: + f.write('korpus=' + upload_metadata['corpus_name']) + f.write('\nime=' + form_data['ime']) + f.write('\npodjetje=' + form_data['podjetje']) + f.write('\nnaslov=' + form_data['naslov']) + f.write('\nposta=' + form_data['posta']) + f.write('\nemail=' + form_data['email']) + f.write('\ndatoteke=' + str(sorted_f_hashes)) + f.write('\npogodba=' + contract) + + +def store_datafiles(uploads_path, files, upload_metadata): + base = get_subdir(uploads_path, 'files') + file_hashes = upload_metadata['file_hashes'] + + for key, f in files.items(): + if key.startswith('file'): + path = base / file_hashes[f.filename] + if not path.exists(): + path.mkdir() + f.save(path / f.filename) + + +def generate_contract_pdf(uploads_path, upload_metadata, contract_client_contact): + base = get_subdir(uploads_path, 'contracts') + contract_file_name = upload_metadata['upload_id'] + '.pdf' + form_data = upload_metadata['form_data'] + + files_table_str = [] + for file_name in upload_metadata['file_names']: + files_table_str.append('') + files_table_str.append(file_name) + files_table_str.append('') + files_table_str = ''.join(files_table_str) + + data = { + 'ime_priimek': form_data['ime'], + 'naslov': form_data['naslov'], + 'posta': form_data['posta'], + 'kontakt_narocnik': contract_client_contact, + 'kontakt_imetnikpravic': form_data['ime'], + 'files_table_str': files_table_str + } + + contract_creator.create_pdf(base / contract_file_name, data) + return contract_file_name + + +def send_confirm_mail(subject, body, uploads_path, upload_metadata, mail_host, mail_login, mail_pass, imap_port=993, smtp_port=465): + upload_id = upload_metadata['upload_id'] + + message = MIMEMultipart() + message['From'] = mail_login + message['To'] = upload_metadata['form_data']['email'] + message['Subject'] = subject.format(upload_id=upload_id) + body = body.format(upload_id=upload_id) + message.attach(MIMEText(body, "plain")) + + contracts_dir = get_subdir(uploads_path, 'contracts') + base_name = upload_metadata['contract'] + contract_file = contracts_dir / base_name + with open(contract_file, "rb") as f: + part = MIMEApplication( + f.read(), + Name = base_name + ) + part['Content-Disposition'] = 'attachment; filename="%s"' % base_name + message.attach(part) + + text = message.as_string() + + # Create a secure SSL context + context = ssl.create_default_context() + + with SMTP_SSL(mail_host, smtp_port, context=context) as server: + server.login(mail_login, mail_pass) + server.sendmail(message['From'], message['To'], text) + + # Save copy of sent mail in Sent mailbox + imap = imaplib.IMAP4_SSL(mail_host, imap_port) + imap.login(mail_login, mail_pass) + imap.append('Sent', '\\Seen', imaplib.Time2Internaldate(time.time()), text.encode('utf8')) + imap.logout() + diff --git a/static/image/logo.svg b/static/image/logo.svg new file mode 100644 index 0000000..ac104c9 --- /dev/null +++ b/static/image/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/style.css b/static/style.css index 2adeb80..f542623 100644 --- a/static/style.css +++ b/static/style.css @@ -37,6 +37,18 @@ html { margin-left: -388px; } +#logo-container { + position: absolute; + top: -9%; + left: 35%; + background: #F5F5F5; + border-radius: 50%; + padding-top: 5%; + padding-bottom: 5%; + padding-left: 7%; + padding-right: 7%; +} + #izjava { float: left; width: 32px; @@ -51,10 +63,22 @@ html { font-size: 36px; line-height: 42px; margin-block-start: 0.4em; + z-index: 5; color: #006cb7; } +#subtitle { + font-family: Roboto; + font-style: bold; + font-weight: 400; + font-size: 15px; + line-height: 20px; + margin-block-start: 0.4em; + border-bottom: 3px solid #006cb7; + color: #006cb7; +} + label { font-family: Roboto; font-style: normal; @@ -160,8 +184,8 @@ input { position: relative; display: inline-block; vertical-align: top; - margin-top: 16px; - margin-bottom: 16px; + margin-top: 26px; + margin-bottom: 26px; min-height: 100px; max-height: 500px; top: -530px; @@ -329,6 +353,7 @@ input { #popup-terms { position: absolute; + z-index: 9999; top: 100px; bottom: 100px; left: 10%; diff --git a/templates/basic.html b/templates/basic.html new file mode 100644 index 0000000..ea60ca3 --- /dev/null +++ b/templates/basic.html @@ -0,0 +1,300 @@ + + + + + Portal za oddajanje besedil + + + {{ dropzone.style('position: absolute; + top: -0.5px; + width: 388px; + height: 632px; + left: 385px; + background: linear-gradient(198.62deg, rgba(255, 255, 255, 0.49) -1.62%, rgba(255, 255, 255, 0.73) -1.61%, rgba(255, 255, 255, 0.41) 79.34%); + box-shadow: 20px 4px 40px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(20px); + border: 0px; + border-radius: 0px 20px 20px 0px;') }} + + + +
+
+
+
+ logo +
+ +
+
+

Portal za oddajanje besedil {{corpus_name}}

+

{{subtitle}}

+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+
+ + + + + + + + diff --git a/templates/index.html b/templates/index.html index f175766..230ff28 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,350 +1,10 @@ - + - - Portal za oddajanje besedil za DS4 in DS1 - - - {{ dropzone.style('position: absolute; - top: -0.5px; - width: 388px; - height: 632px; - left: 385px; - background: linear-gradient(198.62deg, rgba(255, 255, 255, 0.49) -1.62%, rgba(255, 255, 255, 0.73) -1.61%, rgba(255, 255, 255, 0.41) 79.34%); - box-shadow: 20px 4px 40px rgba(0, 0, 0, 0.25); - backdrop-filter: blur(20px); - border: 0px; - border-radius: 0px 20px 20px 0px;') }} - + -
-
-
-
-
-

Portal za oddajanje besedil za DS4 in DS1

- -
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- -
-
- - - - - - + Korpus paralelnih besdil ANG-SLO
+ Korpus Gigafida