From e1b7e525df9bc6b152e0bde9b50dbecf4b915ead Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Tue, 7 Jan 2020 00:32:48 -0600 Subject: [PATCH] Fix #355 --- VERSION | 2 +- requirements.txt | 2 +- source/app/controllers/main.py | 6 + source/app/controllers/utils.py | 131 +++++++++++++ source/app/models/db.py | 7 +- source/app/models/main.py | 105 +++++++--- source/app/seafileapi/__init__.py | 5 + source/app/seafileapi/admin.py | 7 + source/app/seafileapi/client.py | 77 ++++++++ source/app/seafileapi/exceptions.py | 25 +++ source/app/seafileapi/files.py | 250 ++++++++++++++++++++++++ source/app/seafileapi/group.py | 22 +++ source/app/seafileapi/repo.py | 99 ++++++++++ source/app/seafileapi/repos.py | 26 +++ source/app/seafileapi/utils.py | 57 ++++++ source/app/settings.py | 14 +- source/static/js/controller/invoices.js | 47 ++++- 17 files changed, 841 insertions(+), 41 deletions(-) create mode 100644 source/app/seafileapi/__init__.py create mode 100644 source/app/seafileapi/admin.py create mode 100644 source/app/seafileapi/client.py create mode 100644 source/app/seafileapi/exceptions.py create mode 100644 source/app/seafileapi/files.py create mode 100644 source/app/seafileapi/group.py create mode 100644 source/app/seafileapi/repo.py create mode 100644 source/app/seafileapi/repos.py create mode 100644 source/app/seafileapi/utils.py diff --git a/VERSION b/VERSION index 3492b09..359c410 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.31.2 +1.32.0 diff --git a/requirements.txt b/requirements.txt index 21de129..6b2a201 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -falcon +falcon==1.4.1 falcon-multipart Beaker Mako diff --git a/source/app/controllers/main.py b/source/app/controllers/main.py index 41ff153..1db632e 100644 --- a/source/app/controllers/main.py +++ b/source/app/controllers/main.py @@ -311,6 +311,12 @@ class AppInvoices(object): req.context['result'] = self._db.invoice(values, session['userobj']) resp.status = falcon.HTTP_200 + def on_put(self, req, resp): + values = req.params + session = req.env['beaker.session'] + req.context['result'] = self._db.invoice_put(values, session['userobj']) + resp.status = falcon.HTTP_200 + def on_delete(self, req, resp): values = req.params session = req.env['beaker.session'] diff --git a/source/app/controllers/utils.py b/source/app/controllers/utils.py index 192ff6f..f653679 100644 --- a/source/app/controllers/utils.py +++ b/source/app/controllers/utils.py @@ -19,9 +19,17 @@ import base64 import collections import datetime +import json +import logging import math +import os +import shutil import smtplib +import sqlite3 +import subprocess +import threading import zipfile +from pathlib import Path from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase @@ -38,8 +46,21 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from dateutil import parser +import seafileapi + + +LOG_FORMAT = '%(asctime)s - %(levelname)s - %(message)s' +LOG_DATE = '%d/%m/%Y %H:%M:%S' +logging.addLevelName(logging.ERROR, '\033[1;41mERROR\033[1;0m') +logging.addLevelName(logging.DEBUG, '\x1b[33mDEBUG\033[1;0m') +logging.addLevelName(logging.INFO, '\x1b[32mINFO\033[1;0m') +logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT, datefmt=LOG_DATE) +log = logging.getLogger(__name__) +logging.getLogger('peewee').setLevel(logging.WARNING) + TIMEOUT = 10 +PATH_INVOICES = 'facturas' #~ https://github.com/kennethreitz/requests/blob/v1.2.3/requests/structures.py#L37 @@ -233,6 +254,23 @@ class CfdiToDict(object): self._values.update({'divisas': d}) return + +def _call(args): + return subprocess.check_output(args, shell=True).decode() + + +def _join(*paths): + return os.path.join(*paths) + + +def run_in_thread(fn): + def run(*k, **kw): + t = threading.Thread(target=fn, args=k, kwargs=kw) + t.start() + return t + return run + + def send_mail(data): msg = '' ok = True @@ -293,3 +331,96 @@ def to_zip(files): return zip_buffer.getvalue() + +def db_delete(user, path): + dt = datetime.datetime.now().strftime('%y%m%d_%H%M') + path_bk = _join(path, 'tmp', '{}_{}.bk'.format(user, dt)) + args = 'pg_dump -U postgres -Fc {} > "{}"'.format(user, path_bk) + try: + _call(args) + except: + pass + + args = 'psql -U postgres -c "DROP DATABASE {0};"'.format(user) + try: + _call(args) + except: + pass + + args = 'psql -U postgres -c "DROP ROLE {0};"'.format(user) + try: + _call(args) + except: + pass + return + + +def _get_pass(rfc): + return rfc + + +def _backup_db(rfc, data, path_bk, is_mv, url_seafile): + if data['type'] != 'postgres': + return + + log.info('Generando backup de: {}'.format(rfc)) + bk_name = '{}.bk'.format(rfc.lower()) + path_db = _join(path_bk, bk_name) + args = 'pg_dump -U postgres -Fc {} > "{}"'.format(data['name'], path_db) + result = _call(args) + log.info('\tBackup local generado...') + if is_mv: + path_target = _join(Path.home(), PATH_INVOICES) + if Path(path_target).exists(): + path_target = _join(path_target, bk_name) + shutil.copy(path_db, path_target) + else: + log.error('\tNo existe la carpeta compartida...') + + sql = 'select correo_timbrado, token_soporte from emisor;' + args = 'psql -U postgres -d {} -Atc "{}"'.format(data['name'], sql) + result = _call(args) + if not result: + log.error('\tSin datos para backup remoto') + return + + data = result.strip().split('|') + if not data[1]: + log.error('\tSin token de soporte') + return + # ~ email = data[0] + # ~ uuid = data[1] + # ~ email = 'hola@elmau.net' + # ~ uuid = 'cc42c591-cf66-499a-ae70-c09df5646be9' + + # ~ log.debug(url_seafile, email, _get_pass(rfc)) + # ~ client = seafileapi.connect(url_seafile, email, _get_pass(rfc)) + # ~ repo = client.repos.get_repo(uuid) + # ~ print(repo) + + return + + +def db_backup(path_companies, path_bk, is_mv, url_seafile): + con = sqlite3.connect(path_companies) + cursor = con.cursor() + sql = "SELECT * FROM names" + cursor.execute(sql) + rows = cursor.fetchall() + if rows is None: + return + cursor.close() + con.close() + + for rfc, data in rows: + _backup_db(rfc, json.loads(data), path_bk, is_mv, url_seafile) + + return + + +def now(): + return datetime.datetime.now().replace(microsecond=0) + + +def get_days(date): + return (now() - date).days diff --git a/source/app/models/db.py b/source/app/models/db.py index 690f434..78c04da 100644 --- a/source/app/models/db.py +++ b/source/app/models/db.py @@ -124,8 +124,8 @@ class StorageEngine(object): # ~ def _get_cancelinvoice(self, values): # ~ return main.Facturas.cancel(values['id']) - def _get_statussat(self, values): - return main.Facturas.get_status_sat(values['id']) + # ~ def _get_statussat(self, values): + # ~ return main.Facturas.get_status_sat(values['id']) def _get_verifysat(self, values): return main.Facturas.get_verify_sat(values['id']) @@ -349,6 +349,9 @@ class StorageEngine(object): return main.Facturas.add(values, user) + def invoice_put(self, values, user): + return main.Facturas.put(values, user) + def preinvoice(self, values): id = int(values.pop('id', '0')) #~ if id: diff --git a/source/app/models/main.py b/source/app/models/main.py index 115a183..df26dba 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -29,22 +29,23 @@ if __name__ == '__main__': parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, parent_dir) -# ~ v2 -from controllers import utils - -# ~ v1 from controllers import util -from settings import log, DEBUG, VERSION, PATH_CP, COMPANIES, PRE, CURRENT_CFDI, \ +from settings import log, DEBUG, COMPANIES, VERSION, PATH_CP, PRE, CURRENT_CFDI, \ INIT_VALUES, DEFAULT_PASSWORD, DECIMALES, IMPUESTOS, DEFAULT_SAT_PRODUCTO, \ CANCEL_SIGNATURE, PUBLIC, DEFAULT_SERIE_TICKET, CURRENT_CFDI_NOMINA, \ DEFAULT_SAT_NOMINA, DECIMALES_TAX, TITLE_APP, MV, DECIMALES_PRECIOS, \ DEFAULT_CFDIPAY, CURRENCY_MN +# ~ v2 +from controllers import utils from settings import ( + DB_COMPANIES, EXT, + IS_MV, MXN, PATHS, + URL, VALUES_PDF, ) @@ -75,7 +76,8 @@ def conectar(opt): if not db_type in db: log.error('Tipo de base de datos no soportado') return False - #~ print ('DB NAME', db_name) + + opt['host'] = opt.get('host', 'localhost') database = db[db_type](db_name, **opt) try: database_proxy.initialize(database) @@ -4284,6 +4286,9 @@ class Facturas(BaseModel): if values['opt'] == 'detalle': return FacturasDetalle.get_detalle(int(values['id'])) + if values['opt'] == 'statussat': + return self.get_status_sat(int(values['id'])) + cfdis = util.loads(values['cfdis']) if values['year'] == '-1': @@ -4907,6 +4912,12 @@ class Facturas(BaseModel): if obj.estatus_sat != estatus_sat: obj.estatus_sat = estatus_sat obj.save() + + if obj.estatus_sat == 'Vigente' and obj.estatus == 'Cancelada': + days = utils.get_days(obj.fecha_cancelacion) + if days > 3: + estatus_sat = 'uncancel' + return estatus_sat @classmethod @@ -4964,21 +4975,6 @@ class Facturas(BaseModel): } return - @util.run_in_thread - def _update_inventory(self, invoice, cancel=False): - if invoice.tipo_comprobante != 'I': - return - - products = FacturasDetalle.get_by_invoice(invoice.id) - for p in products: - if p.producto.inventario: - if cancel: - p.producto.existencia += Decimal(p.cantidad) - else: - p.producto.existencia -= Decimal(p.cantidad) - p.producto.save() - return - @classmethod def timbrar(cls, values, user): id = int(values['id']) @@ -5278,15 +5274,64 @@ class Facturas(BaseModel): return {'ok': False} # ~ v2 - @classmethod - def uncancel(cls, id): - obj = Facturas.get(Facturas.id==id) - if obj.uuid is None: - msg = 'La factura no esta timbrada' - return {'ok': False, 'msg': msg} + @utils.run_in_thread + def _update_inventory(self, invoice, cancel=False): + if invoice.tipo_comprobante != 'I': + return + products = FacturasDetalle.get_by_invoice(invoice.id) + for p in products: + if p.producto.inventario: + if cancel: + p.producto.existencia += Decimal(p.cantidad) + else: + p.producto.existencia -= Decimal(p.cantidad) + p.producto.save() return + @utils.run_in_thread + def _update_client_balance(self, invoice, cancel=False): + if invoice.tipo_comprobante == 'T': + return + + if invoice.donativo and invoice.forma_pago == '12': + return + + if invoice.cliente.rfc == RFC_PUBLICO: + return + + importe = invoice.total_mn + if invoice.tipo_comprobante == 'E': + importe *= -1 + + if cancel: + importe *= -1 + + q = (Socios + .update(saldo_cliente=Socios.saldo_cliente + importe) + .where(Socios.id==invoice.cliente.id) + ) + return bool(q.execute()) + + def _put_uncancel(self, args, user): + id = int(args['id']) + obj = Facturas.get(Facturas.id==id) + obj.estatus = 'Timbrada' + obj.error = '' + obj.cancelada = False + obj.fecha_cancelacion = None + obj.acuse = '' + self._update_client_balance(self, obj) + self._update_inventory(self, obj) + obj.save() + msg = 'Factura actualizada correctamente' + result = {'result': True, 'msg': msg, 'values': {'estatus': 'Timbrada'}} + return result + + @classmethod + def put(cls, args, user): + return getattr(cls, f"_put_{args['opt']}")(cls, args, user) + class PreFacturas(BaseModel): cliente = ForeignKeyField(Socios) @@ -9416,7 +9461,8 @@ def empresa_agregar(rfc, no_bd): def empresa_borrar(rfc): if _delete_emisor(rfc): - util.delete_db(rfc.lower()) + # ~ util.delete_db(rfc.lower()) + utils.db_delete(rfc.lower(), PATHS['DOCS']) return True @@ -10195,7 +10241,8 @@ def main(iniciar_bd, migrar_bd, nuevo_superusuario, cambiar_contraseña, sys.exit(0) if opt['backup_dbs']: - util.backup_dbs() + # ~ util.backup_dbs() + utils.db_backup(DB_COMPANIES, PATHS['BK'], IS_MV, URL['SEAFILE']) sys.exit(0) if opt['exportar_documentos']: diff --git a/source/app/seafileapi/__init__.py b/source/app/seafileapi/__init__.py new file mode 100644 index 0000000..d6c3b8d --- /dev/null +++ b/source/app/seafileapi/__init__.py @@ -0,0 +1,5 @@ +from seafileapi.client import SeafileApiClient + +def connect(server, username, password): + client = SeafileApiClient(server, username, password) + return client diff --git a/source/app/seafileapi/admin.py b/source/app/seafileapi/admin.py new file mode 100644 index 0000000..08fc2c4 --- /dev/null +++ b/source/app/seafileapi/admin.py @@ -0,0 +1,7 @@ + +class SeafileAdmin(object): + def lists_users(self, maxcount=100): + pass + + def list_user_repos(self, username): + pass diff --git a/source/app/seafileapi/client.py b/source/app/seafileapi/client.py new file mode 100644 index 0000000..52a6ea6 --- /dev/null +++ b/source/app/seafileapi/client.py @@ -0,0 +1,77 @@ +import requests +from seafileapi.utils import urljoin +from seafileapi.exceptions import ClientHttpError +from seafileapi.repos import Repos + +class SeafileApiClient(object): + """Wraps seafile web api""" + def __init__(self, server, username=None, password=None, token=None): + """Wraps various basic operations to interact with seahub http api. + """ + self.server = server + self.username = username + self.password = password + self._token = token + + self.repos = Repos(self) + self.groups = Groups(self) + + if token is None: + self._get_token() + + def _get_token(self): + data = { + 'username': self.username, + 'password': self.password, + } + url = urljoin(self.server, '/api2/auth-token/') + res = requests.post(url, data=data) + if res.status_code != 200: + raise ClientHttpError(res.status_code, res.content) + token = res.json()['token'] + assert len(token) == 40, 'The length of seahub api auth token should be 40' + self._token = token + + def __str__(self): + return 'SeafileApiClient[server=%s, user=%s]' % (self.server, self.username) + + __repr__ = __str__ + + def get(self, *args, **kwargs): + return self._send_request('GET', *args, **kwargs) + + def post(self, *args, **kwargs): + return self._send_request('POST', *args, **kwargs) + + def put(self, *args, **kwargs): + return self._send_request('PUT', *args, **kwargs) + + def delete(self, *args, **kwargs): + return self._send_request('delete', *args, **kwargs) + + def _send_request(self, method, url, *args, **kwargs): + if not url.startswith('http'): + url = urljoin(self.server, url) + + headers = kwargs.get('headers', {}) + headers.setdefault('Authorization', 'Token ' + self._token) + kwargs['headers'] = headers + + expected = kwargs.pop('expected', 200) + if not hasattr(expected, '__iter__'): + expected = (expected, ) + resp = requests.request(method, url, *args, **kwargs) + if resp.status_code not in expected: + msg = 'Expected %s, but get %s' % \ + (' or '.join(map(str, expected)), resp.status_code) + raise ClientHttpError(resp.status_code, msg) + + return resp + + +class Groups(object): + def __init__(self, client): + pass + + def create_group(self, name): + pass diff --git a/source/app/seafileapi/exceptions.py b/source/app/seafileapi/exceptions.py new file mode 100644 index 0000000..b11498d --- /dev/null +++ b/source/app/seafileapi/exceptions.py @@ -0,0 +1,25 @@ + +class ClientHttpError(Exception): + """This exception is raised if the returned http response is not as + expected""" + def __init__(self, code, message): + super(ClientHttpError, self).__init__() + self.code = code + self.message = message + + def __str__(self): + return 'ClientHttpError[%s: %s]' % (self.code, self.message) + +class OperationError(Exception): + """Expcetion to raise when an opeartion is failed""" + pass + + +class DoesNotExist(Exception): + """Raised when not matching resource can be found.""" + def __init__(self, msg): + super(DoesNotExist, self).__init__() + self.msg = msg + + def __str__(self): + return 'DoesNotExist: %s' % self.msg diff --git a/source/app/seafileapi/files.py b/source/app/seafileapi/files.py new file mode 100644 index 0000000..ed01e64 --- /dev/null +++ b/source/app/seafileapi/files.py @@ -0,0 +1,250 @@ +import io +import os +import posixpath +import re +from seafileapi.utils import querystr + +ZERO_OBJ_ID = '0000000000000000000000000000000000000000' + +class _SeafDirentBase(object): + """Base class for :class:`SeafFile` and :class:`SeafDir`. + + It provides implementation of their common operations. + """ + isdir = None + + def __init__(self, repo, path, object_id, size=0): + """ + :param:`path` the full path of this entry within its repo, like + "/documents/example.md" + + :param:`size` The size of a file. It should be zero for a dir. + """ + self.client = repo.client + self.repo = repo + self.path = path + self.id = object_id + self.size = size + + @property + def name(self): + return posixpath.basename(self.path) + + def list_revisions(self): + pass + + def delete(self): + suffix = 'dir' if self.isdir else 'file' + url = '/api2/repos/%s/%s/' % (self.repo.id, suffix) + querystr(p=self.path) + resp = self.client.delete(url) + return resp + + def rename(self, newname): + """Change file/folder name to newname + """ + suffix = 'dir' if self.isdir else 'file' + url = '/api2/repos/%s/%s/' % (self.repo.id, suffix) + querystr(p=self.path, reloaddir='true') + postdata = {'operation': 'rename', 'newname': newname} + resp = self.client.post(url, data=postdata) + succeeded = resp.status_code == 200 + if succeeded: + if self.isdir: + new_dirent = self.repo.get_dir(os.path.join(os.path.dirname(self.path), newname)) + else: + new_dirent = self.repo.get_file(os.path.join(os.path.dirname(self.path), newname)) + for key in list(self.__dict__.keys()): + self.__dict__[key] = new_dirent.__dict__[key] + return succeeded + + def _copy_move_task(self, operation, dirent_type, dst_dir, dst_repo_id=None): + url = '/api/v2.1/copy-move-task/' + src_repo_id = self.repo.id + src_parent_dir = os.path.dirname(self.path) + src_dirent_name = os.path.basename(self.path) + dst_repo_id = dst_repo_id + dst_parent_dir = dst_dir + operation = operation + dirent_type = dirent_type + postdata = {'src_repo_id': src_repo_id, 'src_parent_dir': src_parent_dir, + 'src_dirent_name': src_dirent_name, 'dst_repo_id': dst_repo_id, + 'dst_parent_dir': dst_parent_dir, 'operation': operation, + 'dirent_type': dirent_type} + return self.client.post(url, data=postdata) + + def copyTo(self, dst_dir, dst_repo_id=None): + """Copy file/folder to other directory (also to a different repo) + """ + if dst_repo_id is None: + dst_repo_id = self.repo.id + + dirent_type = 'dir' if self.isdir else 'file' + resp = self._copy_move_task('copy', dirent_type, dst_dir, dst_repo_id) + return resp.status_code == 200 + + def moveTo(self, dst_dir, dst_repo_id=None): + """Move file/folder to other directory (also to a different repo) + """ + if dst_repo_id is None: + dst_repo_id = self.repo.id + + dirent_type = 'dir' if self.isdir else 'file' + resp = self._copy_move_task('move', dirent_type, dst_dir, dst_repo_id) + succeeded = resp.status_code == 200 + if succeeded: + new_repo = self.client.repos.get_repo(dst_repo_id) + dst_path = os.path.join(dst_dir, os.path.basename(self.path)) + if self.isdir: + new_dirent = new_repo.get_dir(dst_path) + else: + new_dirent = new_repo.get_file(dst_path) + for key in list(self.__dict__.keys()): + self.__dict__[key] = new_dirent.__dict__[key] + return succeeded + + def get_share_link(self): + pass + +class SeafDir(_SeafDirentBase): + isdir = True + + def __init__(self, *args, **kwargs): + super(SeafDir, self).__init__(*args, **kwargs) + self.entries = None + self.entries = kwargs.pop('entries', None) + + def ls(self, force_refresh=False): + """List the entries in this dir. + + Return a list of objects of class :class:`SeafFile` or :class:`SeafDir`. + """ + if self.entries is None or force_refresh: + self.load_entries() + + return self.entries + + def share_to_user(self, email, permission): + url = '/api2/repos/%s/dir/shared_items/' % self.repo.id + querystr(p=self.path) + putdata = { + 'share_type': 'user', + 'username': email, + 'permission': permission + } + resp = self.client.put(url, data=putdata) + return resp.status_code == 200 + + def create_empty_file(self, name): + """Create a new empty file in this dir. + Return a :class:`SeafFile` object of the newly created file. + """ + # TODO: file name validation + path = posixpath.join(self.path, name) + url = '/api2/repos/%s/file/' % self.repo.id + querystr(p=path, reloaddir='true') + postdata = {'operation': 'create'} + resp = self.client.post(url, data=postdata) + self.id = resp.headers['oid'] + self.load_entries(resp.json()) + return SeafFile(self.repo, path, ZERO_OBJ_ID, 0) + + def mkdir(self, name): + """Create a new sub folder right under this dir. + + Return a :class:`SeafDir` object of the newly created sub folder. + """ + path = posixpath.join(self.path, name) + url = '/api2/repos/%s/dir/' % self.repo.id + querystr(p=path, reloaddir='true') + postdata = {'operation': 'mkdir'} + resp = self.client.post(url, data=postdata) + self.id = resp.headers['oid'] + self.load_entries(resp.json()) + return SeafDir(self.repo, path, ZERO_OBJ_ID) + + def upload(self, fileobj, filename): + """Upload a file to this folder. + + :param:fileobj :class:`File` like object + :param:filename The name of the file + + Return a :class:`SeafFile` object of the newly uploaded file. + """ + if isinstance(fileobj, str): + fileobj = io.BytesIO(fileobj) + upload_url = self._get_upload_link() + files = { + 'file': (filename, fileobj), + 'parent_dir': self.path, + } + self.client.post(upload_url, files=files) + return self.repo.get_file(posixpath.join(self.path, filename)) + + def upload_local_file(self, filepath, name=None): + """Upload a file to this folder. + + :param:filepath The path to the local file + :param:name The name of this new file. If None, the name of the local file would be used. + + Return a :class:`SeafFile` object of the newly uploaded file. + """ + name = name or os.path.basename(filepath) + with open(filepath, 'r') as fp: + return self.upload(fp, name) + + def _get_upload_link(self): + url = '/api2/repos/%s/upload-link/' % self.repo.id + resp = self.client.get(url) + return re.match(r'"(.*)"', resp.text).group(1) + + def get_uploadable_sharelink(self): + """Generate a uploadable shared link to this dir. + + Return the url of this link. + """ + pass + + def load_entries(self, dirents_json=None): + if dirents_json is None: + url = '/api2/repos/%s/dir/' % self.repo.id + querystr(p=self.path) + dirents_json = self.client.get(url).json() + + self.entries = [self._load_dirent(entry_json) for entry_json in dirents_json] + + def _load_dirent(self, dirent_json): + path = posixpath.join(self.path, dirent_json['name']) + if dirent_json['type'] == 'file': + return SeafFile(self.repo, path, dirent_json['id'], dirent_json['size']) + else: + return SeafDir(self.repo, path, dirent_json['id'], 0) + + @property + def num_entries(self): + if self.entries is None: + self.load_entries() + return len(self.entries) if self.entries is not None else 0 + + def __str__(self): + return 'SeafDir[repo=%s,path=%s,entries=%s]' % \ + (self.repo.id[:6], self.path, self.num_entries) + + __repr__ = __str__ + +class SeafFile(_SeafDirentBase): + isdir = False + + def update(self, fileobj): + """Update the content of this file""" + pass + + def __str__(self): + return 'SeafFile[repo=%s,path=%s,size=%s]' % \ + (self.repo.id[:6], self.path, self.size) + + def _get_download_link(self): + url = '/api2/repos/%s/file/' % self.repo.id + querystr(p=self.path) + resp = self.client.get(url) + return re.match(r'"(.*)"', resp.text).group(1) + + def get_content(self): + """Get the content of the file""" + url = self._get_download_link() + return self.client.get(url).content + + __repr__ = __str__ diff --git a/source/app/seafileapi/group.py b/source/app/seafileapi/group.py new file mode 100644 index 0000000..731d7ef --- /dev/null +++ b/source/app/seafileapi/group.py @@ -0,0 +1,22 @@ + + +class Group(object): + def __init__(self, client, group_id, group_name): + self.client = client + self.group_id = group_id + self.group_name = group_name + + def list_memebers(self): + pass + + def delete(self): + pass + + def add_member(self, username): + pass + + def remove_member(self, username): + pass + + def list_group_repos(self): + pass diff --git a/source/app/seafileapi/repo.py b/source/app/seafileapi/repo.py new file mode 100644 index 0000000..01811a2 --- /dev/null +++ b/source/app/seafileapi/repo.py @@ -0,0 +1,99 @@ +from urllib.parse import urlencode +from seafileapi.files import SeafDir, SeafFile +from seafileapi.utils import raise_does_not_exist + +class Repo(object): + """ + A seafile library + """ + def __init__(self, client, repo_id, repo_name, + encrypted, owner, perm): + self.client = client + self.id = repo_id + self.name = repo_name + self.encrypted = encrypted + self.owner = owner + self.perm = perm + + @classmethod + def from_json(cls, client, repo_json): + + repo_id = repo_json['id'] + repo_name = repo_json['name'] + encrypted = repo_json['encrypted'] + perm = repo_json['permission'] + owner = repo_json['owner'] + + return cls(client, repo_id, repo_name, encrypted, owner, perm) + + def is_readonly(self): + return 'w' not in self.perm + + @raise_does_not_exist('The requested file does not exist') + def get_file(self, path): + """Get the file object located in `path` in this repo. + + Return a :class:`SeafFile` object + """ + assert path.startswith('/') + url = '/api2/repos/%s/file/detail/' % self.id + query = '?' + urlencode(dict(p=path)) + file_json = self.client.get(url + query).json() + + return SeafFile(self, path, file_json['id'], file_json['size']) + + @raise_does_not_exist('The requested dir does not exist') + def get_dir(self, path): + """Get the dir object located in `path` in this repo. + + Return a :class:`SeafDir` object + """ + assert path.startswith('/') + url = '/api2/repos/%s/dir/' % self.id + query = '?' + urlencode(dict(p=path)) + resp = self.client.get(url + query) + dir_id = resp.headers['oid'] + dir_json = resp.json() + dir = SeafDir(self, path, dir_id) + dir.load_entries(dir_json) + return dir + + def delete(self): + """Remove this repo. Only the repo owner can do this""" + self.client.delete('/api2/repos/' + self.id) + + def list_history(self): + """List the history of this repo + + Returns a list of :class:`RepoRevision` object. + """ + pass + + ## Operations only the repo owner can do: + + def update(self, name=None): + """Update the name of this repo. Only the repo owner can do + this. + """ + pass + + def get_settings(self): + """Get the settings of this repo. Returns a dict containing the following + keys: + + `history_limit`: How many days of repo history to keep. + """ + pass + + def restore(self, commit_id): + pass + +class RepoRevision(object): + def __init__(self, client, repo, commit_id): + self.client = client + self.repo = repo + self.commit_id = commit_id + + def restore(self): + """Restore the repo to this revision""" + self.repo.revert(self.commit_id) diff --git a/source/app/seafileapi/repos.py b/source/app/seafileapi/repos.py new file mode 100644 index 0000000..70a8fa7 --- /dev/null +++ b/source/app/seafileapi/repos.py @@ -0,0 +1,26 @@ +from seafileapi.repo import Repo +from seafileapi.utils import raise_does_not_exist + +class Repos(object): + def __init__(self, client): + self.client = client + + def create_repo(self, name, password=None): + data = {'name': name} + if password: + data['passwd'] = password + repo_json = self.client.post('/api2/repos/', data=data).json() + return self.get_repo(repo_json['repo_id']) + + @raise_does_not_exist('The requested library does not exist') + def get_repo(self, repo_id): + """Get the repo which has the id `repo_id`. + + Raises :exc:`DoesNotExist` if no such repo exists. + """ + repo_json = self.client.get('/api2/repos/' + repo_id).json() + return Repo.from_json(self.client, repo_json) + + def list_repos(self): + repos_json = self.client.get('/api2/repos/').json() + return [Repo.from_json(self.client, j) for j in repos_json] diff --git a/source/app/seafileapi/utils.py b/source/app/seafileapi/utils.py new file mode 100644 index 0000000..7903414 --- /dev/null +++ b/source/app/seafileapi/utils.py @@ -0,0 +1,57 @@ +import string +import random +from functools import wraps +from urllib.parse import urlencode +from seafileapi.exceptions import ClientHttpError, DoesNotExist + +def randstring(length=0): + if length == 0: + length = random.randint(1, 30) + return ''.join(random.choice(string.lowercase) for i in range(length)) + +def urljoin(base, *args): + url = base + if url[-1] != '/': + url += '/' + for arg in args: + arg = arg.strip('/') + url += arg + '/' + if '?' in url: + url = url[:-1] + return url + +def raise_does_not_exist(msg): + """Decorator to turn a function that get a http 404 response to a + :exc:`DoesNotExist` exception.""" + def decorator(func): + @wraps(func) + def wrapped(*args, **kwargs): + try: + return func(*args, **kwargs) + except ClientHttpError as e: + if e.code == 404: + raise DoesNotExist(msg) + else: + raise + return wrapped + return decorator + +def to_utf8(obj): + if isinstance(obj, str): + return obj.encode('utf-8') + return obj + +def querystr(**kwargs): + return '?' + urlencode(kwargs) + +def utf8lize(obj): + if isinstance(obj, dict): + return {k: to_utf8(v) for k, v in obj.items()} + + if isinstance(obj, list): + return [to_utf8(x) for x in ob] + + if instance(obj, str): + return obj.encode('utf-8') + + return obj diff --git a/source/app/settings.py b/source/app/settings.py index d9752e8..fcb3570 100644 --- a/source/app/settings.py +++ b/source/app/settings.py @@ -47,11 +47,12 @@ except ImportError: DEBUG = DEBUG -VERSION = '1.31.2' +VERSION = '1.32.0' EMAIL_SUPPORT = ('soporte@empresalibre.mx',) TITLE_APP = '{} v{}'.format(TITLE_APP, VERSION) BASE_DIR = os.path.abspath(os.path.dirname(__file__)) +COMPANIES = os.path.abspath(os.path.join(BASE_DIR, '..', 'db', 'rfc.db')) path_static = os.path.abspath(os.path.join(BASE_DIR, '..', 'static')) path_docs = os.path.abspath(os.path.join(BASE_DIR, '..', 'docs')) @@ -196,6 +197,10 @@ API = 'https://api.empresalibre.net{}' CURRENCY_MN = 'MXN' # ~ v2 +IS_MV = MV +DB_COMPANIES = os.path.abspath(os.path.join(BASE_DIR, '..', 'db', 'rfc.db')) +path_bk = os.path.join(path_docs, 'tmp') + EXT = { 'CSS': 'css', 'HTML': 'html', @@ -204,6 +209,7 @@ EXT = { 'JSON': 'json', } MXN = 'MXN' + PATHS = { 'STATIC': path_static, 'CSS': path_css, @@ -211,7 +217,9 @@ PATHS = { 'DOCS': path_docs, 'USER': path_user_template, 'LOGOS': path_user_logos, + 'BK': path_bk, } + VALUES_PDF = { 'CANCEL': {True: 'inline', False: 'none'}, 'TYPE': {'I': 'Ingreso', 'E': 'Egreso', 'T': 'Traslado'}, @@ -221,3 +229,7 @@ VALUES_PDF = { 'PPD': 'Pago en parcialidades o diferido', }, } + +URL = { + 'SEAFILE': 'https://seafile.elmau.net', +} diff --git a/source/static/js/controller/invoices.js b/source/static/js/controller/invoices.js index 47c2982..1983bcd 100644 --- a/source/static/js/controller/invoices.js +++ b/source/static/js/controller/invoices.js @@ -508,7 +508,6 @@ function generar_anticipo_egreso(id){ function send_timbrar(id){ - //~ webix.ajax().get('/values/timbrar', {id: id}, function(text, data){ webix.ajax().post('invoices', {opt: 'timbrar', id: id}, function(text, data){ var values = data.json() if(values.ok){ @@ -1322,7 +1321,6 @@ function grid_invoices_click(id, e, node){ function send_cancel(id){ - //~ webix.ajax().get('/values/cancelinvoice', {id: id}, function(text, data){ webix.ajax().post('invoices', {opt: 'cancel', id: id}, function(text, data){ var values = data.json() if(values.ok){ @@ -1338,6 +1336,7 @@ function send_cancel(id){ }) } + function cmd_invoice_cancelar_click(){ if(gi.count() == 0){ return @@ -1435,6 +1434,37 @@ function filter_dates_change(range){ } +function invoice_uncancel(id){ + var options = {opt: 'uncancel', id: id} + webix.ajax().put('/invoices', options, function(text, data){ + var result = data.json() + if(result['result']){ + gi.updateItem(id, result['values']) + msg_ok(result['msg']) + }else{ + msg_error(result['msg']) + } + }) +} + + +function ask_invoice_uncancel(id){ + var msg = 'La factura seleccionada esta Vigente en el SAT, pero Cancelada en el sistema
¿Deseas cambiar su estatus?' + webix.confirm({ + title: 'Cambiar estatus de factura', + ok: 'Si', + cancel: 'No', + type: 'confirm-error', + text: msg, + callback:function(result){ + if (result){ + invoice_uncancel(id) + } + } + }) +} + + function cmd_invoice_sat_click(){ if(gi.count() == 0){ return @@ -1456,12 +1486,15 @@ function cmd_invoice_sat_click(){ return } - webix.ajax().get('/values/statussat', {id: row.id}, function(text, data){ - var values = data.json() - if(values == 'Vigente'){ - msg_ok(values) + var options = {opt: 'statussat', id: row.id} + webix.ajax().get('/invoices', options, function(text, data){ + var value = data.json() + if(value == 'Vigente'){ + msg_ok(value) + }else if(value == 'uncancel'){ + ask_invoice_uncancel(row.id) }else{ - msg_error(values) + msg_error(value) } })