diff --git a/Dockerfile b/Dockerfile index 46484a4..2ae20f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,6 @@ WORKDIR /usr/src/portal-webapp RUN apt-get update && apt-get -y install wkhtmltopdf && \ rm -rf /var/lib/apt/lists/* -RUN pip3 install --no-cache-dir pdfkit flask flask-dropzone flask-log-request-id flask-login Flask-SQLAlchemy alembic flask-migrate Flask-script psycopg2 gunicorn pdfkit Werkzeug +RUN pip3 install --no-cache-dir pdfkit flask==1.1.4 flask-dropzone flask-log-request-id flask-login Flask-SQLAlchemy alembic flask-migrate Flask-script psycopg2 gunicorn pdfkit Werkzeug==1.0.1 ENTRYPOINT ["./entrypoint.sh"] diff --git a/app.py b/app.py index 8ca882a..296830f 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,6 @@ import logging import os import configparser -import re from pathlib import Path from werkzeug.security import check_password_hash @@ -14,6 +13,8 @@ from portal.model import db, RegisteredUser import portal.base import portal.solar +import portal.regular +import portal.predavanja # TODO: Implement user registration. @@ -45,6 +46,7 @@ MAIL_BODY = config['MAIL_BODY'] SQL_CONN_STR = config['SQL_CONN_STR'] DESC_PREVODI = config['DESC_PREVODI'] DESC_GIGAFIDA = config['DESC_GIGAFIDA'] +DESC_PREDAVANJA = config['DESC_PREDAVANJA'] if 'UPLOADS_DIR' in config: UPLOADS_DIR = Path(config['UPLOADS_DIR']) @@ -85,7 +87,8 @@ if 'PORTALDS4DS1_DESC_PREVODI' in os.environ: if 'PORTALDS4DS1_DESC_GIGAFIDA' in os.environ: DESC_GIGAFIDA = os.environ['PORTALDS4DS1_DESC_GIGAFIDA'] -VALID_CORPUS_NAMES = ['prevodi', 'gigafida', 'solar'] +ENABLED_CORPUSES = ['prevodi', 'gigafida', 'solar', 'predavanja'] +CORPUSES_LOGIN_REQUIRED = ['solar'] ###################### @@ -110,7 +113,33 @@ manager.add_command('db', MigrateCommand) # Set up dropzone.js to serve all the stuff for "file dropping" on the web interface. dropzone = Dropzone(app) -upload_handler = portal.base.UploadHandler( +upload_handler_regular = portal.regular.UploadHandlerRegular( + UPLOADS_DIR=UPLOADS_DIR, + MAIL_HOST=MAIL_HOST, + MAIL_LOGIN=MAIL_LOGIN, + MAIL_PASS=MAIL_PASS, + SMTP_PORT=SMTP_PORT, + IMAP_PORT=IMAP_PORT, + MAIL_SUBJECT=MAIL_SUBJECT, + MAIL_BODY=MAIL_BODY, + CONTRACT_CLIENT_CONTACT=CONTRACT_CLIENT_CONTACT, + MAX_FILES_PER_UPLOAD=MAX_FILES_PER_UPLOAD + ) + +upload_handler_solar = portal.solar.UploadHandlerSolar( + UPLOADS_DIR=UPLOADS_DIR, + MAIL_HOST=MAIL_HOST, + MAIL_LOGIN=MAIL_LOGIN, + MAIL_PASS=MAIL_PASS, + SMTP_PORT=SMTP_PORT, + IMAP_PORT=IMAP_PORT, + MAIL_SUBJECT=MAIL_SUBJECT, + MAIL_BODY=MAIL_BODY, + CONTRACT_CLIENT_CONTACT=CONTRACT_CLIENT_CONTACT, + MAX_FILES_PER_UPLOAD=MAX_FILES_PER_UPLOAD + ) + +upload_handler_predavanja = portal.predavanja.UploadHandlerPredavanja( UPLOADS_DIR=UPLOADS_DIR, MAIL_HOST=MAIL_HOST, MAIL_LOGIN=MAIL_LOGIN, @@ -141,13 +170,15 @@ def index(): @app.route('/') def index_corpus(corpus_name): - if corpus_name not in VALID_CORPUS_NAMES: + if corpus_name not in ENABLED_CORPUSES: return 'Korpus "{}" ne obstaja.'.format(corpus_name), 404 if corpus_name == 'prevodi': description = DESC_PREVODI elif corpus_name == 'gigafida': description = DESC_GIGAFIDA + elif corpus_name == 'predavanja': + return render_template('basic-predavanja.html', description=DESC_PREDAVANJA, max_files=MAX_FILES_PER_UPLOAD) elif corpus_name == 'solar': if current_user.is_authenticated: return redirect('/solar/oddaja') @@ -164,11 +195,16 @@ def load_user(user_id): @app.route('//login') def login_get(corpus_name): - return render_template('login.html', corpus_name=corpus_name) + if corpus_name == 'solar': + return render_template('login.html', corpus_name=corpus_name, title='ŠOLAR') + return 404 @app.route('//login', methods=['POST']) def login_post(corpus_name): + if corpus_name not in ENABLED_CORPUSES or corpus_name not in CORPUSES_LOGIN_REQUIRED: + return 404 + email = request.form.get('email') password = request.form.get('password') remember = True if request.form.get('remember') else False @@ -189,35 +225,48 @@ def login_post(corpus_name): if corpus_name == 'solar': return redirect('/solar/oddaja') - return redirect('/{}/home'.format(corpus_name)) - - -@app.route('//home') -@login_required -def profile(corpus_name): - return render_template('login.html', corpus_name=corpus_name) + return 404 # TODO: Move solar stuff to seperate file using Flask blueprints. - @app.route('/solar/oddaja') @login_required def solar_oddaja(): return render_template('solar-oddaja.html') +@app.route('/solar/zgodvina') +@login_required +def solar_zgodovina(): + return render_template('solar-zgodovina.html') + + +@app.route('/solar/pogodbe') +@login_required +def solar_pogodbe(): + return render_template('solar-pogodbe.html') + + +@app.route('/solar/admin') +@login_required +def solar_admin(): + # TODO: check if user is admin + return render_template('solar-admin.html') + + @app.route('//upload', methods=['POST']) def handle_upload(corpus_name): - if corpus_name not in VALID_CORPUS_NAMES: + if corpus_name not in ENABLED_CORPUSES: return 404 if corpus_name == 'solar': if current_user.is_authenticated: - return portal.solar.handle_upload(request, upload_handler) + return upload_handler_solar.handle_upload(request, current_user.get_id()) return 404 + elif corpus_name == 'predavanja': + return upload_handler_predavanja.handle_upload(request) else: - return portal.base.handle_upload_unauthenticated(request, corpus_name) - + return upload_handler_regular.handle_upload(request, corpus_name) if __name__ == '__main__': diff --git a/config.ini b/config.ini index 0328a3b..0b41d03 100644 --- a/config.ini +++ b/config.ini @@ -12,6 +12,7 @@ UPLOADS_DIR=./uploads CONTRACT_CLIENT_CONTACT=Testko Tester DESC_PREVODI=

Prevodi

Strojno prevajanje je ena od uporabnih jezikovnih tehnologij, saj omogoča hitro sporazumevanje med ljudmi iz različnih kultur in jezikovnih okolij. Več o razvoju slovenskega strojnega prevajalnika lahko preberete na tej povezavi. Za kakovosten strojni prevajalnik so ključnega pomena prevodi, iz kateri se algoritmi umetne inteligence naučijo prevajati. S prispevanjem besedil v korpus prevodov boste pomembno prispevali k razvoju slovenskega strojnega prevajalnika med angleščino in slovenščino. Več informacij o prispevanju besedil najdete tukaj.

DESC_GIGAFIDA=

Gigafida

Gigafida je referenčni korpus pisne slovenščine. Besedila so izbrana in strojno obdelana z namenom, da bi korpus kot vzorec sodobne standardne slovenščine lahko služil za jezikoslovne in druge humanistične raziskave, izdelavo sodobnih slovarjev, slovnic, učnih gradiv in razvoj jezikovnih tehnologij za slovenščino. S prispevanjem besedil v korpus Gigafida pomembno prispevate k razvoju sodobnih jezikovnih tehnologij za slovenski jezik.

+DESC_PREDAVANJA=

Predavanja

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. diff --git a/contract/predavanja.html b/contract/predavanja.html new file mode 100644 index 0000000..f84e376 --- /dev/null +++ b/contract/predavanja.html @@ -0,0 +1,30 @@ + + + + + + + + + {{ime_priimek}} + +

Priloga k pogodbi o prenosu avtorskih pravic: seznam avtorskih del, ki so predmet pogodbe

+
+ + + + + {{files_table_str}} +
Ime, naslov ali oznaka dela
+
+ diff --git a/contract/template.html b/contract/regular.html similarity index 100% rename from contract/template.html rename to contract/regular.html diff --git a/migrations/versions/a846faa2b908_initial_migration.py b/migrations/versions/a846faa2b908_initial_migration.py index bfedc10..fb635b4 100644 --- a/migrations/versions/a846faa2b908_initial_migration.py +++ b/migrations/versions/a846faa2b908_initial_migration.py @@ -18,15 +18,15 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('upload_unauthenticated', + op.create_table('upload_regular', sa.Column('id', sa.Integer(), nullable=False), - sa.Column('upload_hash', sa.String(), nullable=True), - sa.Column('timestamp', sa.DateTime(), nullable=True), - sa.Column('form_name', sa.String(), nullable=True), - sa.Column('form_org', sa.String(), nullable=True), - sa.Column('form_address', sa.String(), nullable=True), - sa.Column('form_zipcode', sa.String(), nullable=True), - sa.Column('form_email', sa.String(), nullable=True), + sa.Column('upload_hash', sa.String(), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('org', sa.String(), nullable=True), + sa.Column('address', sa.String(), nullable=True), + sa.Column('zipcode', sa.String(), nullable=True), + sa.Column('email', sa.String(), nullable=False), sa.Column('file_contract', sa.String(), nullable=True), sa.Column('upload_file_hashes', sa.ARRAY(sa.String()), nullable=True), sa.PrimaryKeyConstraint('id') @@ -36,5 +36,5 @@ def upgrade(): def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('upload_unauthenticated') + op.drop_table('upload_regular') # ### end Alembic commands ### diff --git a/migrations/versions/c6edf87b8bff_added_tables_for_solar_data_and_.py b/migrations/versions/c6edf87b8bff_added_tables_for_solar_data_and_.py index 42d9f01..8cb7565 100644 --- a/migrations/versions/c6edf87b8bff_added_tables_for_solar_data_and_.py +++ b/migrations/versions/c6edf87b8bff_added_tables_for_solar_data_and_.py @@ -22,13 +22,13 @@ def downgrade(): op.drop_table('institution') op.drop_table('stamps') op.drop_table('registered_user') - op.drop_column('upload_unauthenticated', 'corpus_name') + op.drop_column('upload_regular', 'corpus_name') # ### end Alembic commands ### def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('upload_unauthenticated', sa.Column('corpus_name', sa.TEXT(), autoincrement=False, nullable=False)) + op.add_column('upload_regular', sa.Column('corpus_name', sa.TEXT(), autoincrement=False, nullable=False)) op.create_table('institution', sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('institution_id_seq'::regclass)"), autoincrement=True, nullable=False), sa.Column('name', sa.TEXT(), autoincrement=False, nullable=False), @@ -66,12 +66,14 @@ def upgrade(): sa.Column('upload_hash', sa.TEXT(), autoincrement=False, nullable=False), sa.Column('timestamp', postgresql.TIMESTAMP(), autoincrement=False, nullable=False), sa.Column('corpus_name', sa.TEXT(), autoincrement=False, nullable=False), - sa.Column('form_program', sa.TEXT(), autoincrement=False, nullable=True), - sa.Column('form_subject', sa.TEXT(), autoincrement=False, nullable=True), - sa.Column('form_grade', sa.INTEGER(), autoincrement=False, nullable=True), - sa.Column('form_text_type', sa.TEXT(), autoincrement=False, nullable=True), - sa.Column('form_school_year', sa.TEXT(), autoincrement=False, nullable=True), - sa.Column('form_grammar_corrections', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('program', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('subject', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('subject_custom', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('grade', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('text_type', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('text_type_custom', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('school_year', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('grammar_corrections', sa.TEXT(), autoincrement=False, nullable=True), sa.Column('upload_file_hashes', sa.ARRAY(sa.String()), nullable=True), sa.ForeignKeyConstraint(['upload_user'], ['registered_user.id'], name='upload_upload_user_fkey'), sa.ForeignKeyConstraint(['institution'], ['institution.id'], name='upload_institution_fkey'), @@ -80,4 +82,22 @@ def upgrade(): # Insert default admin user with username "admin" and pass "portal-admin". op.execute('INSERT INTO registered_user(name, email, role, pass_hash, active) VALUES (\'admin\', \'admin@cjvt.si\', \'admin\', \'pbkdf2:sha256:150000$aPRDrEqF$f27256d6d57001770feb9e7012ea27252f4a3e5ea9989931368e466d798679ff\', TRUE);') + + op.create_table('upload_predavanja', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('upload_hash', sa.String(), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('address', sa.String(), nullable=False), + sa.Column('subject', sa.String(), nullable=False), + sa.Column('faculty', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('phone', sa.String(), nullable=True), + sa.Column('keywords', sa.String(), nullable=False), + sa.Column('agree_publish', sa.Boolean(), nullable=False), + sa.Column('file_contract', sa.String(), nullable=True), + sa.Column('upload_file_hashes', sa.ARRAY(sa.String()), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### diff --git a/portal/base.py b/portal/base.py index f9f3e56..3a7eac9 100644 --- a/portal/base.py +++ b/portal/base.py @@ -2,6 +2,7 @@ import hashlib import time import ssl import traceback +import re from pathlib import Path from datetime import datetime @@ -18,19 +19,22 @@ from email.mime.application import MIMEApplication import pdfkit from jinja2 import Environment, FileSystemLoader -from . model import db, UploadUnauthenticated, UploadSolar +from . model import db, UploadRegular, UploadSolar, RegisteredUser 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}$') +MAX_FNAME_LEN = 100 + class ContractCreator: - def __init__(self): + def __init__(self, base_path, template_path): + self.base = base_path template_loader = FileSystemLoader(searchpath="./") template_env = Environment(loader=template_loader) - self.template = template_env.get_template('contract/template.html') + self.template = template_env.get_template(template_path) self.pdfkit_options = { 'page-size': 'A4', @@ -47,7 +51,11 @@ class ContractCreator: def fill_template(self, **kwargs): return self.template.render(**kwargs) - def create_pdf(self, out_f, fields_dict): + def create_pdf(self, f_name, fields_dict): + sub_dir = self.base / Path(f_name[:2]) + if not sub_dir.exists(): + sub_dir.mkdir() + out_f = sub_dir / Path(f_name[2:]) html_str = self.fill_template(**fields_dict) pdfkit.from_string(html_str, out_f, options=self.pdfkit_options) @@ -56,17 +64,26 @@ class UploadHandler: def __init__(self, **kwargs): self.config = kwargs + + def set_contract_creator(self, contract_creator): + assert isinstance(contract_creator, ContractCreator) + self._contract_creator = contract_creator - self.contract_creator = ContractCreator() + def get_uploads_subdir(self, dir_name): + subdir = Path(self.config['UPLOADS_DIR']) / dir_name + if not subdir.exists(): + subdir.mkdir(parents=True) + return subdir - def extract_upload_metadata(self, corpus_name, request): + @staticmethod + def extract_upload_metadata(corpus_name, request): upload_metadata = dict() - file_hashes = self.create_file_hashes(request.files) + file_hashes = UploadHandler.create_file_hashes(request.files) file_names = file_hashes.keys() form_data = request.form.copy() upload_timestamp = int(time.time()) - upload_id = self.create_upload_id(corpus_name, form_data, upload_timestamp, file_hashes) + upload_id = UploadHandler.create_upload_id(corpus_name, form_data, upload_timestamp, file_hashes) upload_metadata['corpus_name'] = corpus_name upload_metadata['form_data'] = form_data @@ -78,14 +95,8 @@ class UploadHandler: return upload_metadata - def get_uploads_subdir(self, dir_name): - subdir = Path(self.config['UPLOADS_DIR']) / dir_name - if not subdir.exists(): - subdir.mkdir(parents=True) - return subdir - - - def create_upload_id(self, corpus_name, form_data, upload_timestamp, file_hashes): + @staticmethod + def create_upload_id(corpus_name, form_data, upload_timestamp, file_hashes): # Order is important while hashing, hence the sorting. val_buff = [str(upload_timestamp)] for key in sorted(form_data): @@ -102,7 +113,8 @@ class UploadHandler: return metahash - def create_file_hashes(self, files): + @staticmethod + def create_file_hashes(files): res = dict() for key, f in files.items(): if key.startswith('file'): @@ -112,57 +124,14 @@ class UploadHandler: f.seek(0) return res - def store_metadata_unauthenticated(self, upload_metadata): - timestamp = datetime.fromtimestamp(upload_metadata['timestamp']) - form_data = upload_metadata['form_data'] - file_hashes = upload_metadata['file_hashes_dict'] - sorted_f_hashes = list(file_hashes.values()) - sorted_f_hashes.sort() - - try: - upload_unauthenticated = UploadUnauthenticated( - upload_hash=upload_metadata['upload_id'], - timestamp=timestamp, - form_name=form_data['ime'], - form_org=form_data['podjetje'], - form_address=form_data['naslov'], - form_zipcode=form_data['posta'], - form_email=form_data['email'], - file_contract=upload_metadata['contract_file'], - upload_file_hashes=sorted_f_hashes, - corpus_name=todo - ) - - db.session.add(upload_unauthenticated) - db.session.commit() - except Exception: - traceback.print_exc() - - def store_metadata_solar(self, upload_metadata): - timestamp = datetime.fromtimestamp(upload_metadata['timestamp']) - form_data = upload_metadata['form_data'] - file_hashes = upload_metadata['file_hashes_dict'] - sorted_f_hashes = list(file_hashes.values()) - sorted_f_hashes.sort() - + @staticmethod + def store_model(self, model_obj): try: - upload_solar = UploadSolar( - upload_user = todo, - institution = todo, - upload_hash=upload_metadata['upload_id'], - timestamp=timestamp, - form_program=form_data['program'], - form_subject=form_data['subject'], - form_grade=form_data['grade'], - form_text_type=form_data['text_type'], - form_school_year=form_data['school_year'], - form_grammar_corrections=form_data['grammar_corrections'], - upload_file_hashes=sorted_f_hashes - ) - db.session.add(upload_unauthenticated) + db.session.add(model_obj) db.session.commit() except Exception: traceback.print_exc() + def store_datafiles(self, files, upload_metadata): base = self.get_uploads_subdir('files') @@ -170,32 +139,18 @@ class UploadHandler: for key, f in files.items(): if key.startswith('file'): - path = base / file_hashes[f.filename] + f_hash = file_hashes[f.filename] + + # First byte used for indexing, similarly like git does for example. + sub_dir = base / f_hash[:2] + if not sub_dir.exists(): + sub_dir.mkdir() + + path = sub_dir / f_hash[2:] if not path.exists(): path.mkdir() f.save(path / f.filename) - def generate_upload_contract_pdf(self, upload_metadata): - base = self.get_uploads_subdir('contracts') - 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': self.config['CONTRACT_CLIENT_CONTACT'], - 'kontakt_imetnikpravic': form_data['ime'], - 'files_table_str': files_table_str - } - - self.contract_creator.create_pdf(base / upload_metadata['contract_file'], data) def send_confirm_mail(self, upload_metadata): upload_id = upload_metadata['upload_id'] @@ -207,15 +162,16 @@ class UploadHandler: body = self.config['MAIL_BODY'].format(upload_id=upload_id) message.attach(MIMEText(body, "plain")) - contracts_dir = self.get_uploads_subdir('contracts') - base_name = upload_metadata['contract_file'] - contract_file = contracts_dir / base_name + contracts_dir = self.contract_creator.base + f_name = upload_metadata['contract_file'] + sub_dir = contracts_dir / Path(f_name[:2]) + contract_file = sub_dir / Path(f_name[2:]) with open(contract_file, "rb") as f: part = MIMEApplication( f.read(), - Name = base_name + Name = f_name ) - part['Content-Disposition'] = 'attachment; filename="%s"' % base_name + part['Content-Disposition'] = 'attachment; filename="%s"' % f_name message.attach(part) text = message.as_string() @@ -223,89 +179,57 @@ class UploadHandler: # Create a secure SSL context context = ssl.create_default_context() - with SMTP_SSL(self.config['MAIL_HOST'], self.config['SMTP_PORT'], context=context) as server: - server.login(self.config['MAIL_LOGIN'], self.config['MAIL_PASS']) - server.sendmail(message['From'], message['To'], text) - - # Save copy of sent mail in Sent mailbox - imap = imaplib.IMAP4_SSL(self.config['MAIL_HOST'], self.config['IMAP_PORT']) - imap.login(self.config['MAIL_LOGIN'], self.config['MAIL_PASS']) - imap.append('Sent', '\\Seen', imaplib.Time2Internaldate(time.time()), text.encode('utf8')) - imap.logout() - - -def handle_upload_unauthenticated(request, corpus_name, upload_handler): - files = request.files - if len(files) > upload_handler.MAX_FILES_PER_UPLOAD: - return 'Naložite lahko do {} datotek hkrati.'.format(upload_handler.MAX_FILES_PER_UPLOAD), 400 - elif len(files) < 1: - return 'Priložena ni bila nobena datoteka.', 400 - - err = check_suffixes(files) - if err: - return err, 400 - - err = check_form(request.form) - if err: - return err, 400 - - # Parse request. - upload_metadata = upload_handler.extract_upload_metadata(corpus_name, request) - - logging.info('Upload with id "{}" supplied form data: {}'.format(upload_metadata['upload_id'], - str(upload_metadata['form_data']))) - - # Generate contract PDF file based on the uploads metadata. - upload_handler.generate_upload_contract_pdf(upload_metadata) - - # Store uploaded files to disk. - upload_handler.store_datafiles(files, upload_metadata) - - # Store metadata to database. - upload_handler.store_metadata_unauthenticated(upload_metadata) - - # Send confirmation mail along with the contract to the submitted email address. - upload_handler.send_confirm_mail(upload_metadata) - - return 'Uspešno ste oddali datotek(e). Št. datotek: {}'.format(len(files)) - - -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 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.' + try: + with SMTP_SSL(self.config['MAIL_HOST'], self.config['SMTP_PORT'], context=context) as server: + server.login(self.config['MAIL_LOGIN'], self.config['MAIL_PASS']) + server.sendmail(message['From'], message['To'], text) + + # Save copy of sent mail in Sent mailbox + imap = imaplib.IMAP4_SSL(self.config['MAIL_HOST'], self.config['IMAP_PORT']) + imap.login(self.config['MAIL_LOGIN'], self.config['MAIL_PASS']) + imap.append('Sent', '\\Seen', imaplib.Time2Internaldate(time.time()), text.encode('utf8')) + imap.logout() + except Exception: + traceback.print_exc() - if len(naslov) > 100: - return 'Predolg naslov.' - if len(posta) > 100: - return 'Predolga pošta' + @staticmethod + 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 - return None + @staticmethod + def check_fname_lengths(files): + for key, f in files.items(): + if key.startswith('file'): + if len(f.filename) > MAX_FNAME_LEN: + return 'Ime datoteke presega dolžino {} znakov.'.format(MAX_FNAME_LEN) + return None + + def check_upload_request(self, request): + files = request.files + max_files = self.config['MAX_FILES_PER_UPLOAD'] + if len(files) > max_files: + return 'Naložite lahko do {} datotek hkrati.'.format(max_files), 400 + elif len(files) < 1: + return 'Priložena ni bila nobena datoteka.', 400 + + err = UploadHandler.check_suffixes(files) + if err: + return err, 400 + + err = UploadHandler.check_fname_lengths(files) + if err: + return err, 400 + + return None + + @staticmethod + def get_user_institution(user_id): + match = db.session.query(RegisteredUser).filter(RegisteredUser.id == user_id).one() + return match.institution diff --git a/portal/model.py b/portal/model.py index d740087..73c304c 100644 --- a/portal/model.py +++ b/portal/model.py @@ -12,22 +12,39 @@ from flask_login import UserMixin db = SQLAlchemy() -# Entries for uploads to corpora, that have no authentication. E.g. "prevodi" or "gigafida". -class UploadUnauthenticated(db.Model): - __tablename__ = 'upload_unauthenticated' +# "prevodi" or "gigafida". +class UploadRegular(db.Model): + __tablename__ = 'upload_regular' id = db.Column(db.Integer, primary_key=True) upload_hash = db.Column(db.String) timestamp = db.Column(db.DateTime, default=datetime.utcnow) - form_name = db.Column(db.String) - form_org = db.Column(db.String) - form_address = db.Column(db.String) - form_zipcode = db.Column(db.String) - form_email = db.Column(db.String) + name = db.Column(db.String) + org = db.Column(db.String) + address = db.Column(db.String) + zipcode = db.Column(db.String) + email = db.Column(db.String) file_contract = db.Column(db.String) upload_file_hashes = db.Column(sqlalchemy.types.ARRAY(db.String)) corpus_name = db.Column(db.String) +class UploadPredavanja(db.Model): + __tablename__ = 'upload_predavanja' + id = db.Column(db.Integer, primary_key=True) + upload_hash = db.Column(db.String) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) + name = db.Column(db.String) + address = db.Column(db.String) + subject = db.Column(db.String) + faculty = db.Column(db.String) + email = db.Column(db.String) + phone = db.Column(db.String) + keywords = db.Column(db.String) + agree_publish = db.Column(db.Boolean) + file_contract = db.Column(db.String) + upload_file_hashes = db.Column(sqlalchemy.types.ARRAY(db.String)) + + class UploadSolar(db.Model): __tablename__ = 'upload_solar' id = db.Column(db.Integer, primary_key=True) @@ -35,13 +52,14 @@ class UploadSolar(db.Model): institution = db.Column(db.Integer, sqlalchemy.ForeignKey('institution.id')) upload_hash = db.Column(db.String) timestamp = db.Column(db.DateTime, default=datetime.utcnow) - corpus_name = db.Column(db.String) - form_program = db.Column(db.String) - form_subject = db.Column(db.String) - form_grade = db.Column(db.Integer) - form_text_type = db.Column(db.String) - form_school_year = db.Column(db.String) - form_grammar_corrections = db.Column(db.String) + program = db.Column(db.String) + subject = db.Column(db.String) + subject_custom = db.Column(db.String) + grade = db.Column(db.Integer) + text_type = db.Column(db.String) + text_type_custom = db.Column(db.String) + school_year = db.Column(db.String) + grammar_corrections = db.Column(db.String) upload_file_hashes = db.Column(sqlalchemy.types.ARRAY(db.String)) diff --git a/portal/predavanja.py b/portal/predavanja.py new file mode 100644 index 0000000..bff29ab --- /dev/null +++ b/portal/predavanja.py @@ -0,0 +1,123 @@ +import logging +import traceback +import re +from datetime import datetime + +import portal.base +from portal.base import UploadHandler, ContractCreator, REGEX_EMAIL +from portal.model import db, UploadPredavanja + + +MAXLEN_FORM = 150 + +class UploadHandlerPredavanja(UploadHandler): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.contract_creator = ContractCreator(base_path=self.get_uploads_subdir('contracts'), + template_path='contract/predavanja.html') + + def generate_upload_contract_pdf(self, upload_metadata): + 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'], + 'files_table_str': files_table_str + } + + self.contract_creator.create_pdf(upload_metadata['contract_file'], data) + + @staticmethod + def store_metadata(upload_metadata): + timestamp = datetime.fromtimestamp(upload_metadata['timestamp']) + form_data = upload_metadata['form_data'] + file_hashes = upload_metadata['file_hashes_dict'] + sorted_f_hashes = list(file_hashes.values()) + sorted_f_hashes.sort() + + try: + model_obj = UploadPredavanja( + upload_hash=upload_metadata['upload_id'], + timestamp=timestamp, + name=form_data['ime'], + address=form_data['naslov-predavanja'], + subject=form_data['predmet'], + faculty=form_data['fakulteta'], + email=form_data['email'], + phone=form_data.get('phone'), + keywords=form_data['kljucne-besede'], + agree_publish=True if 'kljucne-besde' in form_data else False, + file_contract=upload_metadata['contract_file'], + upload_file_hashes=sorted_f_hashes, + ) + db.session.add(model_obj) + db.session.commit() + except Exception: + traceback.print_exc() + + def handle_upload(self, request): + err = self.check_upload_request(request) + if err: + return err, 400 + + err = self.check_form(request.form) + if err: + return err, 400 + + # Parse request. + upload_metadata = self.extract_upload_metadata('predavanja', request) + + logging.info('Upload for "predavanja" with id "{}" supplied form data: {}'.format( + upload_metadata['upload_id'], str(upload_metadata['form_data']))) + + # Generate contract PDF file based on the uploads metadata. + self.generate_upload_contract_pdf(upload_metadata) + + # Store uploaded files to disk. + self.store_datafiles(request.files, upload_metadata) + + # Store metadata to database. + self.store_metadata(upload_metadata) + + # Send confirmation mail along with the contract to the submitted email address. + self.send_confirm_mail(upload_metadata) + + return 'Uspešno ste oddali datotek(e). Št. datotek: {}'.format(len(request.files)) + + + @staticmethod + def check_form(form): + name = form.get('ime') + address = form.get('naslov-predavanja') + subject = form.get('predmet') + faculty = form.get('fakulteta') + email = form.get('email') + phone = form.get('telefon') + keywords = form.get('kljucne-besede') + + if not name \ + or not address \ + or not subject \ + or not faculty \ + or not email \ + or not keywords: + return 'Izpolnite vsa obvezna polja.' + + for keyword in keywords.split(): + if not keyword.isalpha(): + return 'Ključna beseda "{}" ni pravilnega formata.'.format(keyword) + + if not re.search(REGEX_EMAIL, email): + return 'Email napačnega formata.' + + for key, val in form.items(): + if len(val) > MAXLEN_FORM: + return 'Polje "{}" presega dolžino {} znakov.'.format(key, MAXLEN_FORM) + diff --git a/portal/regular.py b/portal/regular.py new file mode 100644 index 0000000..74f164d --- /dev/null +++ b/portal/regular.py @@ -0,0 +1,130 @@ +import logging +import re +import traceback +from datetime import datetime + +from portal.base import UploadHandler, ContractCreator, REGEX_EMAIL +from portal.model import db, UploadRegular + + +class UploadHandlerRegular(UploadHandler): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.contract_creator = ContractCreator(base_path=self.get_uploads_subdir('contracts'), + template_path='contract/regular.html') + + def generate_upload_contract_pdf(self, upload_metadata): + base = self.get_uploads_subdir('contracts') + 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.get('naslov', ''), + 'posta': form_data.get('posta', ''), + 'kontakt_narocnik': self.config['CONTRACT_CLIENT_CONTACT'], + 'kontakt_imetnikpravic': form_data['ime'], + 'files_table_str': files_table_str + } + + self.contract_creator.create_pdf(upload_metadata['contract_file'], data) + + @staticmethod + def store_metadata(upload_metadata, corpus_name): + timestamp = datetime.fromtimestamp(upload_metadata['timestamp']) + form_data = upload_metadata['form_data'] + file_hashes = upload_metadata['file_hashes_dict'] + sorted_f_hashes = list(file_hashes.values()) + sorted_f_hashes.sort() + + try: + model_obj = UploadRegular( + upload_hash=upload_metadata['upload_id'], + timestamp=timestamp, + name=form_data['ime'], + org=form_data.get('podjetje'), + address=form_data.get('naslov'), + zipcode=form_data.get('posta'), + email=form_data['email'], + file_contract=upload_metadata['contract_file'], + upload_file_hashes=sorted_f_hashes, + corpus_name=corpus_name + ) + + db.session.add(model_obj) + db.session.commit() + except Exception: + traceback.print_exc() + + + def handle_upload(self, request, corpus_name): + err = self.check_upload_request(request) + if err: + return err, 400 + + err = self.check_form(request.form) + if err: + return err, 400 + + # Parse request. + upload_metadata = self.extract_upload_metadata(corpus_name, request) + + logging.info('Upload with id "{}" supplied form data: {}'.format(upload_metadata['upload_id'], + str(upload_metadata['form_data']))) + + # Generate contract PDF file based on the uploads metadata. + self.generate_upload_contract_pdf(upload_metadata) + + # Store uploaded files to disk. + self.store_datafiles(request.files, upload_metadata) + + # Store metadata to database. + self.store_metadata(upload_metadata, corpus_name) + + # Send confirmation mail along with the contract to the submitted email address. + self.send_confirm_mail(upload_metadata) + + return 'Uspešno ste oddali datotek(e). Št. datotek: {}'.format(len(request.files)) + + @staticmethod + 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 not ime: + return 'Prazno polje za ime.' + if len(ime) > 100: + return 'Predolgo ime.' + + if podjetje and len(podjetje) > 100: + return 'Predolgo ime institucije.' + + if not email: + return 'Prazno polje za elektronsko pošto.' + if len(email) > 100: + return 'Predolgi email naslov' + elif not re.search(REGEX_EMAIL, email): + return 'Email napačnega formata.' + + if telefon and len(telefon) > 100: + return 'Predolga telefonska št.' + + if naslov and len(naslov) > 100: + return 'Predolg naslov.' + + if posta and len(posta) > 100: + return 'Predolga pošta' + + return None + diff --git a/portal/solar.py b/portal/solar.py index 66d9fcb..65b0d82 100644 --- a/portal/solar.py +++ b/portal/solar.py @@ -1,41 +1,101 @@ +import logging +import re +from datetime import datetime + import portal.base +from portal.base import UploadHandler +from portal.model import UploadSolar + + +VALID_PROGRAMS = {'OS', 'SSG', 'MGP', 'ZG', 'NPI', 'SPI', 'SSI', 'PTI'} +VALID_SUBJECTS = {'slo', 'drug-jez', 'drug-druz', 'drug-narav', 'drug-strok', 'drug-izb'} +VALID_TEXT_TYPES = {'esej-spis', 'prakticno', 'solski-test', 'delo-v-razredu'} +VALID_GRAMMAR_CORRECTIONS = {'popr-ne', 'brez-popr', 'popr-da'} + +MAXLEN_FORM = 150 + + +class UploadHandlerSolar(UploadHandler): + + @staticmethod + def store_metadata(upload_metadata, user_id): + timestamp = datetime.fromtimestamp(upload_metadata['timestamp']) + form_data = upload_metadata['form_data'] + file_hashes = upload_metadata['file_hashes_dict'] + sorted_f_hashes = list(file_hashes.values()) + sorted_f_hashes.sort() + institution_id = UploadHandler.get_user_institution(user_id) -def handle_upload(request, upload_handler): - files = request.files - if len(files) > upload_handler.MAX_FILES_PER_UPLOAD: - return 'Naložite lahko do {} datotek hkrati.'.format(upload_handler.MAX_FILES_PER_UPLOAD), 400 - elif len(files) < 1: - return 'Priložena ni bila nobena datoteka.', 400 + try: + model_obj = UploadSolar( + upload_user = user_id, + institution = institution_id, + upload_hash=upload_metadata['upload_id'], + timestamp=timestamp, + program=form_data['program'], + subject=form_data['predmet'], + subject_custom=form_data['predmet-custom'], + grade=form_data['letnik'], + text_type=form_data['vrsta'], + text_type_custom=form_data['vrsta-custom'], + school_year=form_data['solsko-leto'], + grammar_corrections=form_data['jezikovni-popravki'], + upload_file_hashes=sorted_f_hashes + ) + db.session.add(model_obj) + db.session.commit() + except Exception: + traceback.print_exc() - err = portal.base.check_suffixes(files) - if err: - return err, 400 + def handle_upload(self, request, user_id): + err = portal.base.check_upload_request(request, self) + if err: + return err, 400 - err = check_form(request.form) - if err: - return err, 400 + err = self.check_form(request.form) + if err: + return err, 400 - # Parse request. - upload_metadata = upload_handler.extract_upload_metadata(corpus_name, request) + # Parse request. + upload_metadata = self.extract_upload_metadata('solar', request) - logging.info('Upload from user "{}" with upload id "{}" supplied form data: {}'.format( - request.user, - upload_metadata['upload_id'], - str(upload_metadata['form_data'] - ))) + logging.info('Upload from user "{}" with upload id "{}" supplied form data: {}'.format( + user_id, + upload_metadata['upload_id'], + str(upload_metadata['form_data'] + ))) - # Store uploaded files to disk. - upload_handler.store_datafiles(files, upload_metadata) + # Store uploaded files to disk. + self.store_datafiles(request.files, upload_metadata) - # Store metadata to database. - upload_handler.store_metadata_solar(upload_metadata) + # Store to database. + self.store_metadata(upload_metadata, user_id) - # Send confirmation mail along with the contract to the submitted email address. - upload_handler.send_confirm_mail(upload_metadata) + return 'Uspešno ste oddali datotek(e). Št. datotek: {}'.format(len(request.files)) - return 'Uspešno ste oddali datotek(e). Št. datotek: {}'.format(len(files)) + @staticmethod + def check_form(form): + program = form['program'] + predmet = form['predmet'] + letnik = int(form['letnik']) + vrsta = form['vrsta'] + solsko_leto = form['solsko-leto'] + jezikovni_popravki = form['jezikovni-popravki'] + if program not in VALID_PROGRAMS: + return 'Invalid program "{}"'.format(program) + if predmet not in VALID_SUBJECTS: + return 'Invalid subject "{}"'.format(premdet) + if letnik < 1 or letnik > 9: + return 'Invalid grade: {}'.format(letnik) + if vrsta not in VALID_TEXT_TYPES: + return 'Invalid text type "{}"'.format(vrsta) + if not re.match('^\d{0,2}-\d{0,2}$', solsko_leto): + return 'Invalid school year "{}"'.format(solsko_leto) + if jezikovni_popravki not in VALID_GRAMMAR_CORRECTIONS: + return 'Invalid text type "{}"'.format(jezikovni_popravki) -def check_form(form): - pass + for key, val in form.items(): + if len(val) > MAXLEN_FORM: + return 'Value in form field "{}" exceeds max len of {}'.format(key, MAXLEN_FORM) diff --git a/static/dropzone.js b/static/dropzone.js index 78d3604..22831e8 100644 --- a/static/dropzone.js +++ b/static/dropzone.js @@ -338,13 +338,12 @@ var _createClass = function() { // TODO: find scrollbox eleemnt, if not exist, create one - var scrollbox = this.previewsContainer.querySelector(".scrollbox"); + var scrollbox = this.previewsContainer.querySelector("#dropzone-scrollbox"); if (scrollbox == null) { scrollbox = document.createElement("div"); - scrollbox.setAttribute("class", "scrollbox"); + scrollbox.setAttribute("id", "dropzone-scrollbox"); this.previewsContainer.appendChild(scrollbox); } - console.log(scrollbox) a.previewElement = b.createElement(this.options.previewTemplate.trim()), diff --git a/static/style.css b/static/style.css index 98cd8bc..6ec9232 100644 --- a/static/style.css +++ b/static/style.css @@ -22,7 +22,7 @@ html { } html { - background: url(image/bg.jpeg) no-repeat center center fixed; + background: url(image/bg.jpeg) no-repeat center center fixed; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; @@ -30,7 +30,6 @@ html { overflow-y: scroll; } - #main-window { position: absolute; top: 50%; @@ -43,7 +42,7 @@ html { position: absolute; top: -9%; left: 35%; - background: #F5F5F5; + background: #f5f5f5; border-radius: 50%; padding-top: 5%; padding-bottom: 5%; @@ -133,6 +132,30 @@ label { margin: 0px 10px; } +.button-general { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 10px 40px; + + width: 138px; + height: 41px; + background: #006cb7; + border-radius: 29px; + border: 0px; + + font-family: Roboto; + font-style: normal; + font-weight: normal; + font-size: 16px; + line-height: 21px; + color: #ffffff; + flex: none; + order: 0; + flex-grow: 0; +} + .button-terms:enabled { justify-content: center; align-items: center; @@ -179,7 +202,6 @@ label { margin-top: 15px; } - input { font-family: Roboto; font-style: normal; @@ -193,19 +215,50 @@ input { width: 100%; } +.mock-side { + position: absolute; + top: -0.5px; + width: 388px; + height: 732px; + 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; +} + +select { + font-family: Roboto; + font-style: normal; + font-weight: normal; + font-size: 16px; + line-height: 19px; + color: #46535b; + background: #f5f5f5; + border: 0px; + border-bottom: 2px solid #c4c4c4; + width: 100%; +} + #izjava { width: auto; } -.scrollbox { - position: relative; +#dropzone-scrollbox { + position: absolute; display: inline-block; vertical-align: top; margin-top: 26px; margin-bottom: 26px; min-height: 100px; max-height: 670px; - top: -600px; + top: 0px; overflow-x: hidden; overflow-y: auto; } @@ -336,7 +389,7 @@ input { display: none; } -.corpus-type-selector { +.selection-tabs { width: 100%; display: flex; align-items: center; @@ -345,7 +398,7 @@ input { margin-bottom: 20px; } -.corpus-type-button { +.selection-tab-button { width: 100%; border: 0px; outline: 0px; @@ -359,7 +412,7 @@ input { color: #848c91; } -.corpus-type-button.selected { +.selection-tab-button.selected { width: 100%; border: 0px; outline: 0px; @@ -398,3 +451,8 @@ input { line-height: 19px; color: #46535b; } + +.dz-error-message { + top: 100% !important; + left: 25% !important; +} diff --git a/templates/basic-predavanja.html b/templates/basic-predavanja.html new file mode 100644 index 0000000..fd4d96f --- /dev/null +++ b/templates/basic-predavanja.html @@ -0,0 +1,294 @@ + + + + + Portal za oddajanje besedil + + + {{ dropzone.style('position: absolute; + top: -0.5px; + width: 388px; + height: 732px; + 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

+
{{description|safe}}
+ + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
*Po kliku na gumb “Oddaj” se bo prikazala vsebina pogodobe o odstopu avtorskih pravic. Če se z vsebino strinjate, kliknite gumb “Pošlji”, da podatke posredujete v korpus, po e-pošti pa boste prejeli svoj izvod pogodbe.
+ + +
+ +
+
+ +
+
+ + + + + + + + diff --git a/templates/login.html b/templates/login.html index c8ef3ac..9a41258 100644 --- a/templates/login.html +++ b/templates/login.html @@ -2,38 +2,47 @@ + Portal {{title}} + -
-

Login

-
- {% with messages = get_flashed_messages() %} - {% if messages %} -
- {{ messages[0] }} +
+
+
+
+ logo
- {% endif %} - {% endwith %} -
+

Prijava - {{title}}

-
- -
-
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {{ messages[0] }} +
+ {% endif %} + {% endwith %} + +
+
+ +
+
-
-
- -
+
+
+ +
+
+
+ + +
+ +
-
- -
- - +
+
+
diff --git a/templates/solar-oddaja.html b/templates/solar-oddaja.html index f9fb803..7397ae7 100644 --- a/templates/solar-oddaja.html +++ b/templates/solar-oddaja.html @@ -4,51 +4,35 @@ Portal za oddajanje besedil - + {{ dropzone.style('position: absolute; top: -0.5px; width: 388px; - height: 632px; + height: 732px; 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 + logo
-
-

Korpus ŠOLAR

- - +

Korpus ŠOLAR

+ +
+ + + +
+ + +