YOSOYYONOSOYOTRO commited on
Commit
83bdb4a
·
verified ·
1 Parent(s): bd4729a

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .bandit +26 -0
  2. .dockerignore +46 -0
  3. .env.example +45 -0
  4. .gitattributes +2 -0
  5. _routes.txt +1 -0
  6. add_credits.py +9 -0
  7. add_credits_test.py +9 -0
  8. alembic.ini +121 -0
  9. alembic/README +1 -0
  10. alembic/env.py +69 -0
  11. alembic/script.py.mako +26 -0
  12. alembic/versions/f22e5d0e402f_initial_migration.py +69 -0
  13. app/__init__.py +1 -0
  14. app/admin/__init__.py +0 -0
  15. app/admin/router.py +385 -0
  16. app/architecture/audit.py +204 -0
  17. app/auth/__init__.py +1 -0
  18. app/auth/config.py +161 -0
  19. app/auth/db.py +9 -0
  20. app/auth/manager.py +315 -0
  21. app/auth/models.py +115 -0
  22. app/auth/router.py +661 -0
  23. app/auth/schemas.py +14 -0
  24. app/cache/__init__.py +1 -0
  25. app/cache/ddjj_pep.csv +3 -0
  26. app/cache/redis_client.py +185 -0
  27. app/config.py +109 -0
  28. app/database.py +86 -0
  29. app/main.py +212 -0
  30. app/middleware/csrf.py +131 -0
  31. app/middleware/login_history.py +97 -0
  32. app/middleware/rate_limit.py +315 -0
  33. app/migrations/sqlite_to_pg.py +106 -0
  34. app/payments/router.py +217 -0
  35. app/pyafipws/.gitignore +38 -0
  36. app/pyafipws/.hgtags +14 -0
  37. app/pyafipws/LICENSE +674 -0
  38. app/pyafipws/README.md +208 -0
  39. app/pyafipws/__init__.py +17 -0
  40. app/pyafipws/conf/afip_ca_info.crt +26 -0
  41. app/pyafipws/conf/arba.crt +111 -0
  42. app/pyafipws/conf/comodo.crt +25 -0
  43. app/pyafipws/conf/geotrust.crt +20 -0
  44. app/pyafipws/conf/rece.ini +127 -0
  45. app/pyafipws/conf/thawte.crt +19 -0
  46. app/pyafipws/conf/wsctg.ini +12 -0
  47. app/pyafipws/conf/wslpg.ini +49 -0
  48. app/pyafipws/cot.py +260 -0
  49. app/pyafipws/cot.pyw +298 -0
  50. app/pyafipws/datos/TB_20111111112_000000_20080124_000001.txt +18 -0
.bandit ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bandit Configuration for CrowData (YAML format)
2
+ exclude_dirs:
3
+ - "app/pyafipws"
4
+ - "app/migrations"
5
+ - "tests"
6
+ - ".venv"
7
+ - "venv"
8
+
9
+ skips:
10
+ - "B101" # assert_used
11
+ - "B108" # hardcoded_tmp_directory (vendored)
12
+ - "B301" # pickle (vendored)
13
+ - "B303" # md5/sha1 (vendored AFIP)
14
+ - "B310" # urllib.urlopen (vendored)
15
+ - "B324" # hashlib md5 (vendored AFIP)
16
+ - "B501" # verify=False (scrapers - dev only)
17
+ - "B608" # hardcoded_sql (vendored)
18
+ - "B701" # jinja2_autoescape_false (email templates)
19
+
20
+ severity_level: "medium"
21
+ confidence_level: "medium"
22
+
23
+ format: "json"
24
+ output: "bandit-report.json"
25
+
26
+ recursive: true
.dockerignore ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Archivos sensibles - NUNCA subir
2
+ .env
3
+ *.key
4
+ *.pem
5
+ *.crt
6
+ *.pfx
7
+
8
+ # Base de datos local - no subir (usamos Supabase en producción)
9
+ *.db
10
+ *.sqlite
11
+ *.sqlite3
12
+
13
+ # Cache y archivos temporales
14
+ __pycache__/
15
+ *.pyc
16
+ *.pyo
17
+ *.pyd
18
+ .Python
19
+ *.egg-info/
20
+ dist/
21
+ build/
22
+ .eggs/
23
+
24
+ # Tests y herramientas de desarrollo
25
+ tests/
26
+ pytest.ini
27
+ .coverage
28
+ htmlcov/
29
+
30
+ # Logs
31
+ *.log
32
+ logs/
33
+
34
+ # IDE
35
+ .vscode/
36
+ .idea/
37
+ *.swp
38
+
39
+ # Node (por si hubiera algo de node aquí)
40
+ node_modules/
41
+
42
+ # Caché de Playwright local
43
+ .playwright/
44
+
45
+ # Cache AFIP local
46
+ wsaa_cache.json
.env.example ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CrowData Backend — Configuración
2
+ # COPIAR a .env y completar los valores
3
+
4
+ # Base de datos (SQLite para dev, PostgreSQL para prod)
5
+ DATABASE_URL=sqlite+aiosqlite:///./crowdata.db
6
+ # DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/crowdata
7
+
8
+ # Redis
9
+ REDIS_URL=redis://localhost:6379/0
10
+
11
+ # Seguridad — GENERAR CON: python -c "import secrets; print(secrets.token_urlsafe(48))"
12
+ SECRET_KEY=
13
+ RESET_PASSWORD_TOKEN_SECRET=
14
+ VERIFICATION_TOKEN_SECRET=
15
+
16
+ # Entorno
17
+ ENVIRONMENT=development
18
+ ACCESS_TOKEN_EXPIRE_MINUTES=30
19
+ CACHE_TTL_SECONDS=86400
20
+ PLAYWRIGHT_HEADLESS=true
21
+
22
+ # APIs externas
23
+ GROQ_API_KEY=
24
+ SEARCHAPI_KEY=
25
+ SEARCHAPI_KEYS=[]
26
+ AI_VERIFICATION_ENABLED=true
27
+
28
+ # Email / SMTP
29
+ SMTP_HOST=smtp.proton.me
30
+ SMTP_PORT=587
31
+ SMTP_USER=
32
+ SMTP_PASSWORD=
33
+ SMTP_USE_TLS=true
34
+ FROM_EMAIL=
35
+ FROM_NAME=CrowData
36
+
37
+ # AFIP
38
+ AFIP_CUIT_REPRESENTADA=
39
+
40
+ # MercadoPago (producción)
41
+ # MP_ACCESS_TOKEN=
42
+ # MP_PUBLIC_KEY=
43
+
44
+ # NopeCHA (reCAPTCHA solver)
45
+ NOPECHA_API_KEY=
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ app/cache/ddjj_pep.csv filter=lfs diff=lfs merge=lfs -text
37
+ app/pyafipws/ejemplos/wsfe/delphi/Project1.exe filter=lfs diff=lfs merge=lfs -text
_routes.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ 11
add_credits.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ conn = sqlite3.connect('E:/crowdata/backend/crowdata.db')
3
+ cursor = conn.cursor()
4
+ cursor.execute('UPDATE users SET credits = 9999 WHERE email = "crowsistemas@proton.me"')
5
+ conn.commit()
6
+ print('Credits updated')
7
+ cursor.execute('SELECT email, credits FROM users WHERE email = "crowsistemas@proton.me"')
8
+ print(cursor.fetchall())
9
+ conn.close()
add_credits_test.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ conn = sqlite3.connect('E:/crowdata/backend/crowdata.db')
3
+ cursor = conn.cursor()
4
+ cursor.execute('UPDATE users SET credits = 9999 WHERE email = "test_final11@test.com"')
5
+ conn.commit()
6
+ print('Updated')
7
+ cursor.execute('SELECT email, credits FROM users WHERE email = "test_final11@test.com"')
8
+ print(cursor.fetchall())
9
+ conn.close()
alembic.ini ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts
5
+ # Use forward slashes (/) also on windows to provide an os agnostic path
6
+ script_location = alembic
7
+
8
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
9
+ # Uncomment the line below if you want the files to be prepended with date and time
10
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
11
+ # for all available tokens
12
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
13
+
14
+ # sys.path path, will be prepended to sys.path if present.
15
+ # defaults to the current working directory.
16
+ prepend_sys_path = .
17
+
18
+ # timezone to use when rendering the date within the migration file
19
+ # as well as the filename.
20
+ # If specified, requires the python>=3.9 or backports.zoneinfo library.
21
+ # Any required deps can installed by adding `alembic[tz]` to the pip requirements
22
+ # string value is passed to ZoneInfo()
23
+ # leave blank for localtime
24
+ # timezone =
25
+
26
+ # max length of characters to apply to the "slug" field
27
+ # truncate_slug_length = 40
28
+
29
+ # set to 'true' to run the environment during
30
+ # the 'revision' command, regardless of autogenerate
31
+ # revision_environment = false
32
+
33
+ # set to 'true' to allow .pyc and .pyo files without
34
+ # a source .py file to be detected as revisions in the
35
+ # versions/ directory
36
+ # sourceless = false
37
+
38
+ # version location specification; This defaults
39
+ # to alembic/versions. When using multiple version
40
+ # directories, initial revisions must be specified with --version-path.
41
+ # The path separator used here should be the separator specified by "version_path_separator" below.
42
+ # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
43
+
44
+ # version path separator; As mentioned above, this is the character used to split
45
+ # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
46
+ # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
47
+ # Valid values for version_path_separator are:
48
+ #
49
+ # version_path_separator = :
50
+ # version_path_separator = ;
51
+ # version_path_separator = space
52
+ # version_path_separator = newline
53
+ version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
54
+
55
+ # set to 'true' to search source files recursively
56
+ # in each "version_locations" directory
57
+ # new in Alembic version 1.10
58
+ # recursive_version_locations = false
59
+
60
+ # the output encoding used when revision files
61
+ # are written from script.py.mako
62
+ # output_encoding = utf-8
63
+
64
+ # sqlalchemy.url = driver://user:pass@localhost/dbname
65
+
66
+ # Use the same approach as the app: read from settings
67
+ # The env.py will load the actual URL from app.config.get_settings()
68
+ sqlalchemy.url = sqlite+aiosqlite:///./crowdata.db
69
+
70
+
71
+ [post_write_hooks]
72
+ # post_write_hooks defines scripts or Python functions that are run
73
+ # on newly generated revision scripts. See the documentation for further
74
+ # detail and examples
75
+
76
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
77
+ # hooks = black
78
+ # black.type = console_scripts
79
+ # black.entrypoint = black
80
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
81
+
82
+ # lint with attempts to fix using "ruff" - use the exec runner, execute a binary
83
+ # hooks = ruff
84
+ # ruff.type = exec
85
+ # ruff.executable = %(here)s/.venv/bin/ruff
86
+ # ruff.options = --fix REVISION_SCRIPT_FILENAME
87
+
88
+ # Logging configuration
89
+ [loggers]
90
+ keys = root,sqlalchemy,alembic
91
+
92
+ [handlers]
93
+ keys = console
94
+
95
+ [formatters]
96
+ keys = generic
97
+
98
+ [logger_root]
99
+ level = WARNING
100
+ handlers = console
101
+ qualname =
102
+
103
+ [logger_sqlalchemy]
104
+ level = WARNING
105
+ handlers =
106
+ qualname = sqlalchemy.engine
107
+
108
+ [logger_alembic]
109
+ level = INFO
110
+ handlers =
111
+ qualname = alembic
112
+
113
+ [handler_console]
114
+ class = StreamHandler
115
+ args = (sys.stderr,)
116
+ level = NOTSET
117
+ formatter = generic
118
+
119
+ [formatter_generic]
120
+ format = %(levelname)-5.5s [%(name)s] %(message)s
121
+ datefmt = %H:%M:%S
alembic/README ADDED
@@ -0,0 +1 @@
 
 
1
+ Generic single-database configuration.
alembic/env.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Alembic env.py - Migration environment configuration
2
+ from logging.config import fileConfig
3
+ from sqlalchemy import create_engine
4
+ from alembic import context
5
+
6
+ # Add app to path
7
+ import sys
8
+ import os
9
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
10
+
11
+ # Load app settings
12
+ from app.config import get_settings
13
+ from app.database import Base
14
+
15
+ # Import all models so they register with Base.metadata
16
+ from app.auth import models # noqa: F401
17
+ from app.reports import models as report_models # noqa: F401
18
+
19
+ # this is the Alembic Config object
20
+ config = context.config
21
+
22
+ # Interpret the config file for Python logging
23
+ if config.config_file_name is not None:
24
+ fileConfig(config.config_file_name)
25
+
26
+ # Set target metadata
27
+ target_metadata = Base.metadata
28
+
29
+ # Load database URL from app settings
30
+ settings = get_settings()
31
+ db_url = settings.database_url
32
+ if db_url.startswith("sqlite+aiosqlite://"):
33
+ db_url = db_url.replace("sqlite+aiosqlite://", "sqlite://")
34
+ elif db_url.startswith("postgresql+asyncpg://"):
35
+ db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
36
+ config.set_main_option("sqlalchemy.url", db_url)
37
+
38
+
39
+ def run_migrations_offline() -> None:
40
+ """Run migrations in 'offline' mode."""
41
+ url = config.get_main_option("sqlalchemy.url")
42
+ context.configure(
43
+ url=url,
44
+ target_metadata=target_metadata,
45
+ literal_binds=True,
46
+ dialect_opts={"paramstyle": "named"},
47
+ )
48
+
49
+ with context.begin_transaction():
50
+ context.run_migrations()
51
+
52
+
53
+ def run_migrations_online() -> None:
54
+ """Run migrations in 'online' mode."""
55
+ connectable = create_engine(config.get_main_option("sqlalchemy.url"))
56
+
57
+ with connectable.connect() as connection:
58
+ context.configure(
59
+ connection=connection, target_metadata=target_metadata
60
+ )
61
+
62
+ with context.begin_transaction():
63
+ context.run_migrations()
64
+
65
+
66
+ if context.is_offline_mode():
67
+ run_migrations_offline()
68
+ else:
69
+ run_migrations_online()
alembic/script.py.mako ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ ${upgrades if upgrades else "pass"}
23
+
24
+
25
+ def downgrade() -> None:
26
+ ${downgrades if downgrades else "pass"}
alembic/versions/f22e5d0e402f_initial_migration.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Initial migration
2
+
3
+ Revision ID: f22e5d0e402f
4
+ Revises:
5
+ Create Date: 2026-07-16 15:12:44.678088
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ import fastapi_users_db_sqlalchemy
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = 'f22e5d0e402f'
17
+ down_revision: Union[str, None] = None
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ # ### commands auto generated by Alembic - please adjust! ###
24
+ op.alter_column('login_history', 'user_id',
25
+ existing_type=sa.VARCHAR(length=36),
26
+ type_=fastapi_users_db_sqlalchemy.generics.GUID(),
27
+ existing_nullable=True)
28
+ op.alter_column('users', 'failed_login_attempts',
29
+ existing_type=sa.INTEGER(),
30
+ nullable=False,
31
+ existing_server_default=sa.text('0'))
32
+ op.alter_column('users', 'mfa_enabled',
33
+ existing_type=sa.BOOLEAN(),
34
+ nullable=False,
35
+ existing_server_default=sa.text('(FALSE)'))
36
+ op.alter_column('users', 'mfa_secret',
37
+ existing_type=sa.VARCHAR(length=255),
38
+ type_=sa.Text(),
39
+ existing_nullable=True)
40
+ op.alter_column('users', 'mfa_backup_codes',
41
+ existing_type=sa.VARCHAR(length=500),
42
+ type_=sa.Text(),
43
+ existing_nullable=True)
44
+ # ### end Alembic commands ###
45
+
46
+
47
+ def downgrade() -> None:
48
+ # ### commands auto generated by Alembic - please adjust! ###
49
+ op.alter_column('users', 'mfa_backup_codes',
50
+ existing_type=sa.Text(),
51
+ type_=sa.VARCHAR(length=500),
52
+ existing_nullable=True)
53
+ op.alter_column('users', 'mfa_secret',
54
+ existing_type=sa.Text(),
55
+ type_=sa.VARCHAR(length=255),
56
+ existing_nullable=True)
57
+ op.alter_column('users', 'mfa_enabled',
58
+ existing_type=sa.BOOLEAN(),
59
+ nullable=True,
60
+ existing_server_default=sa.text('(FALSE)'))
61
+ op.alter_column('users', 'failed_login_attempts',
62
+ existing_type=sa.INTEGER(),
63
+ nullable=True,
64
+ existing_server_default=sa.text('0'))
65
+ op.alter_column('login_history', 'user_id',
66
+ existing_type=fastapi_users_db_sqlalchemy.generics.GUID(),
67
+ type_=sa.VARCHAR(length=36),
68
+ existing_nullable=True)
69
+ # ### end Alembic commands ###
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Init files for Python packages
app/admin/__init__.py ADDED
File without changes
app/admin/router.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin API — Private endpoints for site owner."""
2
+
3
+ import logging
4
+ from datetime import datetime, timedelta
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from sqlalchemy import select, func, text, Integer
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from app.auth.models import User
9
+ from app.auth.router import current_active_user
10
+ from app.database import get_db
11
+ from app.reports.models import SearchHistory, ReportCache, MonitorTask
12
+
13
+ logger = logging.getLogger(__name__)
14
+ router = APIRouter(prefix="/admin", tags=["admin"])
15
+
16
+ # Auth
17
+ async def current_active_superuser(user: User = Depends(current_active_user)) -> User:
18
+ if not user.is_superuser:
19
+ raise HTTPException(status_code=403, detail="Not enough permissions")
20
+ return user
21
+
22
+
23
+ @router.get("/stats")
24
+ async def get_stats(
25
+ user: User = Depends(current_active_superuser),
26
+ db: AsyncSession = Depends(get_db),
27
+ ):
28
+ """Estadísticas funcionales completas para el dashboard admin."""
29
+ try:
30
+ now = datetime.utcnow()
31
+ day_ago = now - timedelta(hours=24)
32
+ week_ago = now - timedelta(days=7)
33
+ month_ago = now - timedelta(days=30)
34
+
35
+ # ── USUARIOS ──────────────────────────────────────────────
36
+ result = await db.execute(select(func.count(User.id)))
37
+ total_users = result.scalar() or 0
38
+
39
+ result = await db.execute(select(func.count(User.id)).where(User.is_active == True))
40
+ active_users = result.scalar() or 0
41
+
42
+ result = await db.execute(select(User.plan, func.count(User.id)).group_by(User.plan))
43
+ plans = {row[0] or "free": row[1] for row in result.all()}
44
+
45
+ result = await db.execute(select(func.sum(User.credits)))
46
+ total_credits = result.scalar() or 0
47
+
48
+ result = await db.execute(select(func.count(User.id)).where(User.created_at >= week_ago))
49
+ new_users_week = result.scalar() or 0
50
+
51
+ result = await db.execute(select(func.count(User.id)).where(User.created_at >= month_ago))
52
+ new_users_month = result.scalar() or 0
53
+
54
+ # ── INFORMES (BÚSQUEDAS) ─────────────────────────────────
55
+ result = await db.execute(select(func.count(SearchHistory.id)))
56
+ total_reports = result.scalar() or 0
57
+
58
+ result = await db.execute(
59
+ select(func.count(SearchHistory.id)).where(SearchHistory.created_at >= day_ago)
60
+ )
61
+ reports_24h = result.scalar() or 0
62
+
63
+ result = await db.execute(
64
+ select(func.count(SearchHistory.id)).where(SearchHistory.created_at >= week_ago)
65
+ )
66
+ reports_week = result.scalar() or 0
67
+
68
+ result = await db.execute(
69
+ select(func.count(SearchHistory.id)).where(SearchHistory.created_at >= month_ago)
70
+ )
71
+ reports_month = result.scalar() or 0
72
+
73
+ # Informes por tipo
74
+ result = await db.execute(
75
+ select(SearchHistory.type, func.count(SearchHistory.id)).group_by(SearchHistory.type)
76
+ )
77
+ report_types = {row[0]: row[1] for row in result.all()}
78
+
79
+ # Top identificadores buscados (últimos 7 días)
80
+ result = await db.execute(
81
+ select(
82
+ SearchHistory.identifier,
83
+ SearchHistory.type,
84
+ SearchHistory.name,
85
+ func.count(SearchHistory.id).label("count"),
86
+ )
87
+ .where(SearchHistory.created_at >= week_ago)
88
+ .group_by(SearchHistory.identifier, SearchHistory.type, SearchHistory.name)
89
+ .order_by(func.count(SearchHistory.id).desc())
90
+ .limit(10)
91
+ )
92
+ top_searches = [
93
+ {"identifier": r[0], "type": r[1], "name": r[2], "count": r[3]}
94
+ for r in result.all()
95
+ ]
96
+
97
+ # Informes por día (últimos 7 días)
98
+ result = await db.execute(
99
+ select(
100
+ func.date(SearchHistory.created_at).label("day"),
101
+ func.count(SearchHistory.id).label("count"),
102
+ )
103
+ .where(SearchHistory.created_at >= week_ago)
104
+ .group_by(func.date(SearchHistory.created_at))
105
+ .order_by(func.date(SearchHistory.created_at))
106
+ )
107
+ daily_reports = [{"date": str(r[0]), "count": r[1]} for r in result.all()]
108
+
109
+ # ── REVENUE ESTIMADO ─────────────────────────────────────
110
+ # Precios por plan (ARS/mes)
111
+ PLAN_PRICES = {"free": 0, "basic": 4999, "pro": 14999, "enterprise": 29999}
112
+ revenue_monthly = sum(PLAN_PRICES.get(p, 0) * count for p, count in plans.items())
113
+ revenue_per_user = revenue_monthly / max(total_users, 1)
114
+
115
+ # ── CONVERSIÓN ───────────────────────────────────────────
116
+ paid_users = sum(count for p, count in plans.items() if p != "free")
117
+ conversion_rate = (paid_users / max(total_users, 1)) * 100
118
+
119
+ # ── CACHE ────────────────────────────────────────────────
120
+ result = await db.execute(select(func.count(ReportCache.id)))
121
+ cache_entries = result.scalar() or 0
122
+
123
+ result = await db.execute(select(func.sum(ReportCache.hit_count)))
124
+ total_cache_hits = result.scalar() or 0
125
+
126
+ result = await db.execute(select(func.count(ReportCache.id)).where(ReportCache.hit_count > 0))
127
+ cache_hits_count = result.scalar() or 0
128
+ cache_hit_rate = (cache_hits_count / max(cache_entries, 1)) * 100
129
+
130
+ # ── MONITOREO ────────────────────────────────────────────
131
+ result = await db.execute(
132
+ select(func.count(MonitorTask.id)).where(MonitorTask.active == True)
133
+ )
134
+ active_monitors = result.scalar() or 0
135
+
136
+ # ── SCRAPERS (telemetría en memoria) ──────────────────────
137
+ from app.utils.telemetry import get_scraper_health, get_scrapers_alerts
138
+ scrapers = get_scraper_health()
139
+ alerts = get_scrapers_alerts()
140
+
141
+ scrapers_ok = sum(1 for s in scrapers if s.get("status") in ("ok", "empty"))
142
+ scrapers_error = sum(1 for s in scrapers if s.get("status") == "error")
143
+ scrapers_blocked = sum(1 for s in scrapers if s.get("status") == "blocked")
144
+
145
+ # ── LOGIN HISTORY ────────────────────────────────────────
146
+ from app.auth.models import LoginHistory
147
+ result = await db.execute(
148
+ select(
149
+ func.count(LoginHistory.id).label("total"),
150
+ func.sum(func.cast(LoginHistory.success, Integer)).label("success"),
151
+ ).where(LoginHistory.created_at >= day_ago)
152
+ )
153
+ login_stats = result.one()
154
+ logins_24h = {
155
+ "total": login_stats.total or 0,
156
+ "successful": int(login_stats.success or 0),
157
+ "failed": (login_stats.total or 0) - int(login_stats.success or 0),
158
+ }
159
+
160
+ return {
161
+ "users": {
162
+ "total": total_users,
163
+ "active": active_users,
164
+ "new_this_week": new_users_week,
165
+ "new_this_month": new_users_month,
166
+ "by_plan": plans,
167
+ "total_credits": total_credits,
168
+ },
169
+ "reports": {
170
+ "total": total_reports,
171
+ "last_24h": reports_24h,
172
+ "last_week": reports_week,
173
+ "last_month": reports_month,
174
+ "by_type": report_types,
175
+ "daily": daily_reports,
176
+ "top_searches": top_searches,
177
+ },
178
+ "revenue": {
179
+ "estimated_monthly_ars": revenue_monthly,
180
+ "per_user_ars": round(revenue_per_user, 2),
181
+ "paid_users": paid_users,
182
+ "conversion_rate_pct": round(conversion_rate, 1),
183
+ },
184
+ "cache": {
185
+ "entries": cache_entries,
186
+ "total_hits": total_cache_hits,
187
+ "hit_rate_pct": round(cache_hit_rate, 1),
188
+ },
189
+ "scrapers": {
190
+ "total": len(scrapers),
191
+ "operational": scrapers_ok,
192
+ "with_error": scrapers_error,
193
+ "blocked": scrapers_blocked,
194
+ "alerts": len(alerts),
195
+ },
196
+ "monitors": {
197
+ "active": active_monitors,
198
+ },
199
+ "security": {
200
+ "logins_24h": logins_24h,
201
+ },
202
+ }
203
+ except Exception as e:
204
+ logger.error(f"Error fetching admin stats: {e}")
205
+ raise HTTPException(status_code=500, detail="Error fetching stats")
206
+
207
+
208
+ @router.get("/users")
209
+ async def get_users(
210
+ limit: int = 50,
211
+ offset: int = 0,
212
+ user: User = Depends(current_active_superuser),
213
+ db: AsyncSession = Depends(get_db),
214
+ ):
215
+ """List all users."""
216
+ try:
217
+ result = await db.execute(
218
+ select(User).order_by(User.created_at.desc()).limit(limit).offset(offset)
219
+ )
220
+ users = result.scalars().all()
221
+
222
+ # Count total
223
+ count_result = await db.execute(select(func.count(User.id)))
224
+ total = count_result.scalar() or 0
225
+
226
+ return {
227
+ "total": total,
228
+ "users": [
229
+ {
230
+ "id": str(u.id),
231
+ "email": u.email,
232
+ "full_name": u.full_name,
233
+ "credits": u.credits,
234
+ "plan": u.plan or "free",
235
+ "is_active": u.is_active,
236
+ "is_superuser": u.is_superuser,
237
+ "created_at": u.created_at.isoformat() if u.created_at else None,
238
+ }
239
+ for u in users
240
+ ],
241
+ }
242
+ except Exception as e:
243
+ logger.error(f"Error fetching users: {e}")
244
+ raise HTTPException(status_code=500, detail="Error fetching users")
245
+
246
+
247
+ @router.get("/searches")
248
+ async def get_searches(
249
+ days: int = 7,
250
+ user: User = Depends(current_active_superuser),
251
+ db: AsyncSession = Depends(get_db),
252
+ ):
253
+ """Search analytics for the last N days."""
254
+ try:
255
+ since = datetime.utcnow() - timedelta(days=days)
256
+
257
+ # Searches per day
258
+ result = await db.execute(
259
+ select(
260
+ func.date(SearchHistory.created_at).label("day"),
261
+ func.count(SearchHistory.id).label("count"),
262
+ )
263
+ .where(SearchHistory.created_at >= since)
264
+ .group_by(func.date(SearchHistory.created_at))
265
+ .order_by(func.date(SearchHistory.created_at))
266
+ )
267
+ daily = [{"date": str(row[0]), "count": row[1]} for row in result.all()]
268
+
269
+ # Top searched identifiers
270
+ result = await db.execute(
271
+ select(
272
+ SearchHistory.identifier,
273
+ SearchHistory.type,
274
+ func.count(SearchHistory.id).label("count"),
275
+ )
276
+ .where(SearchHistory.created_at >= since)
277
+ .group_by(SearchHistory.identifier, SearchHistory.type)
278
+ .order_by(func.count(SearchHistory.id).desc())
279
+ .limit(10)
280
+ )
281
+ top_searches = [
282
+ {"identifier": row[0], "type": row[1], "count": row[2]}
283
+ for row in result.all()
284
+ ]
285
+
286
+ # Unique users searching
287
+ result = await db.execute(
288
+ select(func.count(func.distinct(SearchHistory.user_id)))
289
+ .where(SearchHistory.created_at >= since)
290
+ )
291
+ unique_users = result.scalar() or 0
292
+
293
+ return {
294
+ "period_days": days,
295
+ "daily": daily,
296
+ "top_searches": top_searches,
297
+ "unique_users": unique_users,
298
+ }
299
+ except Exception as e:
300
+ logger.error(f"Error fetching search analytics: {e}")
301
+ raise HTTPException(status_code=500, detail="Error fetching search analytics")
302
+
303
+
304
+ @router.get("/scrapers")
305
+ async def get_scrapers(user: User = Depends(current_active_superuser)):
306
+ """Scraper health status from in-memory telemetry."""
307
+ try:
308
+ from app.utils.telemetry import get_scraper_health
309
+
310
+ results = []
311
+ for scraper in get_scraper_health():
312
+ success_rate = scraper.get("success_rate_24h")
313
+ avg_latency = scraper.get("avg_latency_ms_24h")
314
+ if avg_latency is None:
315
+ avg_latency = scraper.get("latency_ms")
316
+
317
+ results.append({
318
+ "name": scraper.get("name"),
319
+ "source": scraper.get("fuente", ""),
320
+ "description": scraper.get("descripcion", ""),
321
+ "status": scraper.get("status", "unknown"),
322
+ "success_rate": (success_rate / 100) if success_rate is not None else None,
323
+ "avg_latency_ms": avg_latency,
324
+ "records_24h": scraper.get("records_found_last", 0),
325
+ "last_check": scraper.get("last_seen"),
326
+ })
327
+ return {"scrapers": results}
328
+ except Exception as e:
329
+ logger.error(f"Error fetching scraper health: {e}")
330
+ raise HTTPException(status_code=500, detail="Error fetching scraper health")
331
+
332
+
333
+ @router.get("/login-history")
334
+ async def get_login_history(
335
+ limit: int = 50,
336
+ user: User = Depends(current_active_superuser),
337
+ db: AsyncSession = Depends(get_db),
338
+ ):
339
+ """Historial de intentos de login (éxito/fallo, IP, timestamp)."""
340
+ try:
341
+ from app.auth.models import LoginHistory
342
+
343
+ result = await db.execute(
344
+ select(LoginHistory).order_by(LoginHistory.created_at.desc()).limit(limit)
345
+ )
346
+ logs = result.scalars().all()
347
+
348
+ count_result = await db.execute(select(func.count(LoginHistory.id)))
349
+ total = count_result.scalar() or 0
350
+
351
+ # Estadísticas de las últimas 24h
352
+ day_ago = datetime.utcnow() - timedelta(hours=24)
353
+ result = await db.execute(
354
+ select(
355
+ func.count(LoginHistory.id).label("total"),
356
+ func.sum(func.cast(LoginHistory.success, Integer)).label("success_count"),
357
+ ).where(LoginHistory.created_at >= day_ago)
358
+ )
359
+ stats_24h = result.one()
360
+ success_24h = stats_24h.success_count or 0
361
+ total_24h = stats_24h.total or 0
362
+
363
+ return {
364
+ "total": total,
365
+ "stats_24h": {
366
+ "total_attempts": total_24h,
367
+ "successful": int(success_24h),
368
+ "failed": total_24h - int(success_24h),
369
+ },
370
+ "logs": [
371
+ {
372
+ "id": l.id,
373
+ "email": l.email,
374
+ "success": l.success,
375
+ "ip_address": l.ip_address,
376
+ "user_agent": l.user_agent[:100] if l.user_agent else None,
377
+ "failure_reason": l.failure_reason,
378
+ "created_at": l.created_at.isoformat() if l.created_at else None,
379
+ }
380
+ for l in logs
381
+ ],
382
+ }
383
+ except Exception as e:
384
+ logger.error(f"Error fetching login history: {e}")
385
+ raise HTTPException(status_code=500, detail="Error fetching login history")
app/architecture/audit.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Auditoría de Arquitectura — CrowData Backend.
3
+
4
+ Ejecutar: python -m app.architecture.audit
5
+ """
6
+ import sys
7
+ sys.path.insert(0, "E:/crowdata/backend")
8
+
9
+ from pathlib import Path
10
+
11
+
12
+ class ArchitectureAudit:
13
+ def __init__(self):
14
+ self.findings = []
15
+ self.passed = []
16
+ self.warnings = []
17
+
18
+ def check(self, name, condition, detail="", severity="HIGH"):
19
+ if condition:
20
+ self.passed.append(f"[PASS] {name}")
21
+ else:
22
+ self.findings.append(f"[FAIL-{severity}] {name}: {detail}")
23
+
24
+ def warn(self, name, detail=""):
25
+ self.warnings.append(f"[WARN] {name}: {detail}")
26
+
27
+ def audit_separation_of_concerns(self):
28
+ """Verifica separación de capas: router -> service -> scraper."""
29
+ backend = Path("E:/crowdata/backend/app")
30
+
31
+ # Routers should not import scrapers directly
32
+ routers = list((backend / "reports").glob("router*.py"))
33
+ router_imports_scraper = False
34
+ for r in routers:
35
+ content = r.read_text(encoding="utf-8")
36
+ if "from app.scrapers" in content:
37
+ router_imports_scraper = True
38
+ self.warn("Router imports scraper", f"{r.name} imports scraper directly")
39
+
40
+ self.check("Routers don't import scrapers", not router_imports_scraper,
41
+ "Some routers import scrapers directly", "MEDIUM")
42
+
43
+ # Services should handle business logic
44
+ service_files = list(backend.glob("**/service*.py"))
45
+ self.check("Service layer exists", len(service_files) > 0,
46
+ "No service files found", "HIGH")
47
+
48
+ # Scrapers should be independent
49
+ scraper_files = list((backend / "scrapers").glob("*.py"))
50
+ self.check("Scrapers exist", len(scraper_files) > 10,
51
+ f"Only {len(scraper_files)} scrapers found", "MEDIUM")
52
+
53
+ def audit_async_patterns(self):
54
+ """Verifica uso correcto de async/await."""
55
+ backend = Path("E:/crowdata/backend/app")
56
+ issues = []
57
+
58
+ for py_file in backend.rglob("*.py"):
59
+ try:
60
+ content = py_file.read_text(encoding="utf-8")
61
+ lines = content.split("\n")
62
+ for i, line in enumerate(lines):
63
+ stripped = line.strip()
64
+ # Check for blocking calls in async context
65
+ if "time.sleep(" in stripped and "async" in content:
66
+ issues.append(f"{py_file.name}:{i+1}: time.sleep in async file")
67
+ if "requests.get(" in stripped or "requests.post(" in stripped:
68
+ issues.append(f"{py_file.name}:{i+1}: sync requests in async file")
69
+ except Exception:
70
+ pass
71
+
72
+ if issues:
73
+ self.warn("Async patterns", f"{len(issues)} potential blocking calls found")
74
+ else:
75
+ self.check("Async patterns correct", True)
76
+
77
+ def audit_connection_pooling(self):
78
+ """Verifica connection pooling."""
79
+ from app.config import get_settings
80
+ settings = get_settings()
81
+
82
+ if settings.database_url.startswith("postgresql"):
83
+ self.check("PostgreSQL configured", True, "Connection pooling available", "INFO")
84
+ else:
85
+ self.warn("SQLite in use", "Connection pooling not applicable for SQLite")
86
+
87
+ def audit_error_handling(self):
88
+ """Verifica manejo de errores consistente."""
89
+ backend = Path("E:/crowdata/backend/app")
90
+ files_with_bare_except = 0
91
+
92
+ for py_file in backend.rglob("*.py"):
93
+ try:
94
+ content = py_file.read_text(encoding="utf-8")
95
+ if "except:" in content or "except Exception:" in content:
96
+ files_with_bare_except += 1
97
+ except Exception:
98
+ pass
99
+
100
+ if files_with_bare_except > 5:
101
+ self.warn("Bare except clauses", f"{files_with_bare_except} files use bare except")
102
+ else:
103
+ self.check("Error handling reasonable", True)
104
+
105
+ def audit_caching_strategy(self):
106
+ """Verifica estrategia de caché."""
107
+ from app.config import get_settings
108
+ settings = get_settings()
109
+
110
+ self.check("Cache TTL configured", settings.cache_ttl_seconds > 0,
111
+ f"TTL: {settings.cache_ttl_seconds}s", "MEDIUM")
112
+ self.check("Redis URL configured", len(settings.redis_url) > 0,
113
+ "Redis URL not set", "MEDIUM")
114
+
115
+ def audit_database_indexes(self):
116
+ """Verifica índices en modelos."""
117
+ from app.reports.models import SearchHistory, ReportCache, MonitorTask
118
+ from app.auth.models import User, LoginHistory
119
+
120
+ # Check SearchHistory indexes
121
+ sh_indexes = [col.name for col in SearchHistory.__table__.columns if col.index]
122
+ self.check("SearchHistory has indexes", len(sh_indexes) > 0,
123
+ f"Indexed columns: {sh_indexes}", "MEDIUM")
124
+
125
+ # Check ReportCache indexes
126
+ rc_indexes = [col.name for col in ReportCache.__table__.columns if col.index]
127
+ self.check("ReportCache has indexes", len(rc_indexes) > 0,
128
+ f"Indexed columns: {rc_indexes}", "MEDIUM")
129
+
130
+ def audit_api_versioning(self):
131
+ """Verifica versionado de API."""
132
+ self.warn("API versioning", "No /v1/ prefix found (consider for production)")
133
+
134
+ def audit_graceful_shutdown(self):
135
+ """Verifica graceful shutdown."""
136
+ main_file = Path("E:/crowdata/backend/app/main.py")
137
+ if main_file.exists():
138
+ content = main_file.read_text(encoding="utf-8")
139
+ has_lifespan = "lifespan" in content
140
+ self.check("Lifespan handler exists", has_lifespan,
141
+ "No lifespan handler for graceful shutdown", "MEDIUM")
142
+ else:
143
+ self.warn("main.py not found", "Cannot verify graceful shutdown")
144
+
145
+ def run(self):
146
+ print("=" * 60)
147
+ print(" AUDITORÍA DE ARQUITECTURA — CrowData Backend")
148
+ print("=" * 60)
149
+ print()
150
+
151
+ print("1. Separación de capas")
152
+ self.audit_separation_of_concerns()
153
+ print()
154
+
155
+ print("2. Patrones Async")
156
+ self.audit_async_patterns()
157
+ print()
158
+
159
+ print("3. Connection Pooling")
160
+ self.audit_connection_pooling()
161
+ print()
162
+
163
+ print("4. Manejo de Errores")
164
+ self.audit_error_handling()
165
+ print()
166
+
167
+ print("5. Estrategia de Caché")
168
+ self.audit_caching_strategy()
169
+ print()
170
+
171
+ print("6. Índices de Base de Datos")
172
+ self.audit_database_indexes()
173
+ print()
174
+
175
+ print("7. Versionado de API")
176
+ self.audit_api_versioning()
177
+ print()
178
+
179
+ print("8. Graceful Shutdown")
180
+ self.audit_graceful_shutdown()
181
+ print()
182
+
183
+ # Results
184
+ print("=" * 60)
185
+ print(" RESULTADOS")
186
+ print("=" * 60)
187
+
188
+ for p in self.passed:
189
+ print(f" {p}")
190
+ for f in self.findings:
191
+ print(f" {f}")
192
+ for w in self.warnings:
193
+ print(f" {w}")
194
+
195
+ print()
196
+ print(f" Passed: {len(self.passed)}")
197
+ print(f" Failed: {len(self.findings)}")
198
+ print(f" Warnings: {len(self.warnings)}")
199
+ print()
200
+
201
+
202
+ if __name__ == "__main__":
203
+ audit = ArchitectureAudit()
204
+ audit.run()
app/auth/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ 
app/auth/config.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi_users.authentication import (
2
+ AuthenticationBackend,
3
+ BearerTransport,
4
+ CookieTransport,
5
+ JWTStrategy,
6
+ )
7
+ from app.config import get_settings
8
+ from typing import Optional, Any
9
+ import jwt
10
+ from jwt import PyJWK
11
+ from datetime import datetime, timedelta
12
+
13
+ settings = get_settings()
14
+
15
+ # Bearer transport (para APIs programáticas / mobile)
16
+ bearer_transport = BearerTransport(tokenUrl="api/auth/jwt/login")
17
+
18
+ # Cookie transport (para navegadores - HttpOnly, Secure, SameSite=Lax)
19
+ cookie_transport = CookieTransport(
20
+ cookie_name="cd_token",
21
+ cookie_max_age=settings.access_token_expire_minutes * 60,
22
+ cookie_secure=not settings.debug, # Secure=True en prod, False en dev local
23
+ cookie_httponly=True,
24
+ cookie_samesite="lax",
25
+ )
26
+
27
+ # ─── Custom Dual JWT Strategy ───
28
+ # Firma nuevos tokens con RS256 (private key)
29
+ # Verifica: intenta RS256 (public key) → si falla, fallback a HS256 (legacy secret)
30
+
31
+ class DualJWTStrategy(JWTStrategy):
32
+ """JWT Strategy que soporta verificación dual: RS256 (nuevo) + HS256 (legacy)."""
33
+
34
+ def __init__(self):
35
+ # Cargar claves
36
+ self._private_key = self._load_private_key()
37
+ self._public_key = self._load_public_key()
38
+ self._legacy_secret = settings.jwt_legacy_secret_key or settings.secret_key
39
+ self._algorithm = settings.jwt_algorithm
40
+ self._legacy_algorithm = settings.jwt_legacy_algorithm
41
+ self._lifetime_seconds = settings.access_token_expire_minutes * 60
42
+ self._key_id = settings.jwt_key_id
43
+
44
+ # Initialize base class with required params (using legacy secret for base compat)
45
+ super().__init__(
46
+ secret=self._legacy_secret,
47
+ lifetime_seconds=self._lifetime_seconds,
48
+ token_audience=["fastapi-users:auth"],
49
+ algorithm=self._algorithm,
50
+ public_key=self._public_key,
51
+ )
52
+
53
+ def _load_private_key(self) -> Optional[str]:
54
+ """Cargar clave privada RS256 desde config o archivo."""
55
+ if settings.jwt_private_key:
56
+ return settings.jwt_private_key
57
+ # Fallback: leer archivo
58
+ import os
59
+ key_path = os.path.join(os.path.dirname(__file__), '..', '..', 'private_key.pem')
60
+ if os.path.exists(key_path):
61
+ with open(key_path, 'r') as f:
62
+ return f.read()
63
+ return None
64
+
65
+ def _load_public_key(self) -> Optional[str]:
66
+ """Cargar clave pública RS256 desde config o archivo."""
67
+ if settings.jwt_public_key:
68
+ return settings.jwt_public_key
69
+ # Fallback: leer archivo
70
+ import os
71
+ key_path = os.path.join(os.path.dirname(__file__), '..', '..', 'public_key.pem')
72
+ if os.path.exists(key_path):
73
+ with open(key_path, 'r') as f:
74
+ return f.read()
75
+ return None
76
+
77
+ async def write_token(self, user) -> str:
78
+ """Firmar token con RS256 (nueva clave privada). Fallar si no hay clave."""
79
+ # Extraer ID del usuario (fastapi-users pasa el objeto User)
80
+ user_id = str(user.id)
81
+ data = {"sub": user_id, "aud": self.token_audience}
82
+
83
+ if not self._private_key:
84
+ raise RuntimeError(
85
+ "JWT_PRIVATE_KEY no configurada. Configure RS256 keys para firmar tokens. "
86
+ "No se permite fallback silencioso a HS256."
87
+ )
88
+
89
+ # Agregar kid en header para rotación de claves
90
+ headers = {"kid": self._key_id}
91
+ return jwt.encode(
92
+ {**data, "exp": datetime.utcnow() + timedelta(seconds=self._lifetime_seconds)},
93
+ self._private_key,
94
+ algorithm=self._algorithm,
95
+ headers=headers,
96
+ )
97
+
98
+ async def read_token(self, token: Optional[str], user_manager) -> Optional[Any]:
99
+ """Verificar token: solo RS256. Legacy HS256 solo si jwt_legacy_enabled=True."""
100
+ if token is None:
101
+ return None
102
+
103
+ # Intentar RS256 (nuevo)
104
+ if self._public_key:
105
+ try:
106
+ data = jwt.decode(
107
+ token,
108
+ self._public_key,
109
+ algorithms=[self._algorithm],
110
+ audience=self.token_audience,
111
+ )
112
+ user_id = data.get("sub")
113
+ if user_id is None:
114
+ return None
115
+ parsed_id = user_manager.parse_id(user_id)
116
+ return await user_manager.get(parsed_id)
117
+ except jwt.PyJWTError:
118
+ pass # Fallar a legacy si habilitado
119
+
120
+ # Legacy HS256 SOLO si explícitamente habilitado
121
+ if settings.jwt_legacy_enabled and self._legacy_secret:
122
+ try:
123
+ data = jwt.decode(
124
+ token,
125
+ self._legacy_secret,
126
+ algorithms=[self._legacy_algorithm],
127
+ audience=self.token_audience,
128
+ )
129
+ user_id = data.get("sub")
130
+ if user_id is None:
131
+ return None
132
+ parsed_id = user_manager.parse_id(user_id)
133
+ return await user_manager.get(parsed_id)
134
+ except jwt.PyJWTError:
135
+ pass
136
+
137
+ # Si llegamos aquí, token inválido
138
+ return None
139
+
140
+ async def destroy_token(self, token: str, user) -> None:
141
+ """JWT es stateless, no hay nada que destruir server-side."""
142
+ pass
143
+
144
+
145
+ def get_jwt_strategy() -> DualJWTStrategy:
146
+ return DualJWTStrategy()
147
+
148
+
149
+ # Dual auth backend: soporta AMBOS transports (header Authorization + cookie)
150
+ auth_backend = AuthenticationBackend(
151
+ name="jwt",
152
+ transport=bearer_transport, # primary para compatibilidad
153
+ get_strategy=get_jwt_strategy,
154
+ )
155
+
156
+ # Segundo backend solo para cookies (se usa en login para setear cookie)
157
+ cookie_auth_backend = AuthenticationBackend(
158
+ name="jwt-cookie",
159
+ transport=cookie_transport,
160
+ get_strategy=get_jwt_strategy,
161
+ )
app/auth/db.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from typing import AsyncGenerator
2
+ from fastapi import Depends
3
+ from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from app.database import get_db
6
+ from app.auth.models import User
7
+
8
+ async def get_user_db(session: AsyncSession = Depends(get_db)):
9
+ yield SQLAlchemyUserDatabase(session, User)
app/auth/manager.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import asyncio
3
+ import logging
4
+ import json
5
+ import secrets
6
+ import re
7
+ from typing import Optional
8
+ from datetime import datetime, timedelta
9
+ from fastapi import Depends, Request, HTTPException
10
+ from fastapi_users import BaseUserManager, UUIDIDMixin
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+ from sqlalchemy import select
13
+ from app.auth.models import User, RefreshToken
14
+ from app.auth.db import get_user_db
15
+ from app.database import get_db, AsyncSessionLocal
16
+ from app.config import get_settings
17
+ from app.utils.security import mask_email
18
+ from app.utils.encryption import encrypt_backup_codes, decrypt_backup_codes
19
+
20
+ logger = logging.getLogger(__name__)
21
+ settings = get_settings()
22
+
23
+ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
24
+ reset_password_token_secret = settings.reset_password_token_secret or settings.secret_key
25
+ verification_token_secret = settings.verification_token_secret or settings.secret_key
26
+
27
+ async def on_after_register(self, user: User, request: Optional[Request] = None):
28
+ logger.info(f"User {user.id} ({mask_email(user.email)}) has registered.")
29
+ try:
30
+ from app.utils.email_service import send_welcome_email
31
+ asyncio.create_task(send_welcome_email(
32
+ to_email=user.email,
33
+ full_name=user.full_name,
34
+ ))
35
+ except Exception as e:
36
+ logger.warning(f"Failed to send welcome email to {mask_email(user.email)}: {e}")
37
+
38
+ async def on_after_forgot_password(
39
+ self, user: User, token: str, request: Optional[Request] = None
40
+ ):
41
+ logger.info(f"User {user.id} has forgot their password. Reset token generated.")
42
+
43
+ async def on_after_request_verify(
44
+ self, user: User, token: str, request: Optional[Request] = None
45
+ ):
46
+ logger.info(f"Verification requested for user {user.id}.")
47
+
48
+ # ─── Password Validation ───
49
+
50
+ async def validate_password(self, password: str, user: Optional[User] = None) -> None:
51
+ """Validate password strength using zxcvbn and check against HIBP."""
52
+ # Minimum length check
53
+ if len(password) < 12:
54
+ raise ValueError("La contraseña debe tener al menos 12 caracteres")
55
+
56
+ # Check for common patterns - only flag long sequences (5+ chars)
57
+ if re.search(r'(.)\1{2,}', password): # 3+ repeated characters
58
+ raise ValueError("La contraseña no debe contener caracteres repetidos (3 o más)")
59
+
60
+ # Check for sequential patterns (5+ chars) - e.g., abcde, 12345, etc.
61
+ sequential_patterns = [
62
+ 'abcde', 'bcdef', 'cdefg', 'defgh', 'efghi', 'fghij', 'ghijk', 'hijkl', 'ijklm', 'jklmn',
63
+ 'klmno', 'lmnop', 'mnopq', 'nopqr', 'opqrs', 'pqrst', 'qrstu', 'rstuv', 'stuvw', 'tuvwx',
64
+ 'uvwxy', 'vwxy', 'wxyz',
65
+ '01234', '12345', '23456', '34567', '45678', '56789'
66
+ ]
67
+ password_lower = password.lower()
68
+ for seq in sequential_patterns:
69
+ if seq in password_lower:
70
+ raise ValueError("La contraseña no debe contener secuencias comunes (5+ caracteres)")
71
+
72
+ # zxcvbn score check (minimum score 3 = good)
73
+ try:
74
+ from zxcvbn import zxcvbn
75
+ result = zxcvbn(password)
76
+ if result['score'] < 3:
77
+ raise ValueError(f"Contraseña muy débil. Mejora: {'; '.join(result['feedback']['suggestions'])}")
78
+
79
+ # Check against HIBP (k-anonymity)
80
+ import httpx
81
+ import hashlib
82
+ sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
83
+ prefix, suffix = sha1[:5], sha1[5:]
84
+
85
+ try:
86
+ with httpx.Client(timeout=5.0) as client:
87
+ resp = client.get(f"https://api.pwnedpasswords.com/range/{prefix}")
88
+ if resp.status_code == 200:
89
+ if suffix in resp.text:
90
+ raise ValueError("Esta contraseña ha sido filtrada en brechas de seguridad conocidas. Usa otra.")
91
+ except httpx.TimeoutException:
92
+ logger.warning("HIBP check timeout, skipping")
93
+ except Exception as e:
94
+ logger.warning(f"HIBP check failed: {e}")
95
+
96
+ except ImportError:
97
+ # zxcvbn not installed, skip advanced checks
98
+ logger.warning("zxcvbn not installed, skipping advanced password checks")
99
+ pass
100
+
101
+ async def authenticate(self, credentials):
102
+ """Override authenticate to add account lockout and password validation."""
103
+ from fastapi_users.exceptions import InvalidPasswordException, UserNotExists, UserInactive
104
+
105
+ # Get user by email
106
+ try:
107
+ user = await self.user_db.get_by_email(credentials.username)
108
+ except Exception:
109
+ user = None
110
+
111
+ if not user:
112
+ raise UserNotExists()
113
+
114
+ if not user.is_active:
115
+ raise UserInactive()
116
+
117
+ # Check account lockout
118
+ if user.locked_until and user.locked_until > datetime.utcnow():
119
+ remaining = int((user.locked_until - datetime.utcnow()).total_seconds() / 60)
120
+ raise HTTPException(
121
+ status_code=403,
122
+ detail=f"Cuenta bloqueada temporalmente. Intente en {remaining} minutos."
123
+ )
124
+
125
+ # Verify password
126
+ is_valid, new_hash = self.password_helper.verify_and_update(credentials.password, user.hashed_password)
127
+ if not is_valid:
128
+ # Increment failed attempts
129
+ user.failed_login_attempts += 1
130
+ if user.failed_login_attempts >= 5:
131
+ user.locked_until = datetime.utcnow() + timedelta(minutes=15)
132
+ logger.warning(f"Account locked for user {user.id} ({mask_email(user.email)}) after 5 failed attempts")
133
+ await self.user_db.update(user)
134
+ raise InvalidPasswordException()
135
+
136
+ # Update hash if it was upgraded (e.g., bcrypt rounds increased)
137
+ if new_hash:
138
+ user.hashed_password = new_hash
139
+ await self.user_db.update(user)
140
+
141
+ # Successful login - reset failed attempts and lock
142
+ if user.failed_login_attempts > 0 or user.locked_until:
143
+ user.failed_login_attempts = 0
144
+ user.locked_until = None
145
+ await self.user_db.update(user)
146
+
147
+ # Verify email is verified
148
+ if not user.is_verified:
149
+ raise HTTPException(
150
+ status_code=403,
151
+ detail="Tu cuenta no ha sido verificada. Revisa tu email para verificar tu cuenta."
152
+ )
153
+
154
+ return user
155
+
156
+ # ─── Refresh Token Methods ───
157
+
158
+ async def create_refresh_token(
159
+ self,
160
+ user: User,
161
+ request: Optional[Request] = None,
162
+ db: Optional[AsyncSession] = None,
163
+ ) -> str:
164
+ """Crear nuevo refresh token y almacenar hash en BD."""
165
+ if db is None:
166
+ async for session in get_db():
167
+ return await self._create_refresh_token_internal(user, request, session)
168
+ return await self._create_refresh_token_internal(user, request, db)
169
+
170
+ async def _create_refresh_token_internal(
171
+ self,
172
+ user: User,
173
+ request: Optional[Request],
174
+ db: AsyncSession,
175
+ ) -> str:
176
+ # Revocar tokens anteriores del usuario (rotación)
177
+ await self.revoke_user_refresh_tokens(user.id, db)
178
+
179
+ # Generar nuevo token
180
+ raw_token = RefreshToken.generate_token()
181
+ token_hash = RefreshToken.hash_token(raw_token)
182
+
183
+ expires_at = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days)
184
+
185
+ # Extraer info de request
186
+ user_agent = request.headers.get("user-agent") if request else None
187
+ ip_address = request.client.host if request and request.client else None
188
+
189
+ refresh_token = RefreshToken(
190
+ user_id=user.id,
191
+ token_hash=token_hash,
192
+ expires_at=expires_at,
193
+ user_agent=user_agent,
194
+ ip_address=ip_address,
195
+ )
196
+ db.add(refresh_token)
197
+ await db.commit()
198
+
199
+ logger.info(f"Refresh token created for user {user.id}")
200
+ return raw_token
201
+
202
+ async def verify_refresh_token(
203
+ self,
204
+ token: str,
205
+ db: Optional[AsyncSession] = None,
206
+ ) -> Optional[User]:
207
+ """Verificar refresh token y retornar usuario si válido."""
208
+ if db is None:
209
+ async for session in get_db():
210
+ return await self._verify_refresh_token_internal(token, session)
211
+ return await self._verify_refresh_token_internal(token, db)
212
+
213
+ async def _verify_refresh_token_internal(
214
+ self,
215
+ token: str,
216
+ db: AsyncSession,
217
+ ) -> Optional[User]:
218
+ token_hash = RefreshToken.hash_token(token)
219
+
220
+ result = await db.execute(
221
+ select(RefreshToken).where(
222
+ RefreshToken.token_hash == token_hash,
223
+ RefreshToken.revoked == False,
224
+ RefreshToken.expires_at > datetime.utcnow(),
225
+ )
226
+ )
227
+ refresh_token = result.scalar_one_or_none()
228
+
229
+ if not refresh_token:
230
+ return None
231
+
232
+ # Obtener usuario
233
+ user = await self.user_db.get(refresh_token.user_id)
234
+ if not user or not user.is_active:
235
+ return None
236
+
237
+ return user
238
+
239
+ async def revoke_refresh_token(
240
+ self,
241
+ token: str,
242
+ db: Optional[AsyncSession] = None,
243
+ ) -> bool:
244
+ """Revocar un refresh token específico."""
245
+ if db is None:
246
+ async for session in get_db():
247
+ return await self._revoke_refresh_token_internal(token, session)
248
+ return await self._revoke_refresh_token_internal(token, db)
249
+
250
+ async def _revoke_refresh_token_internal(
251
+ self,
252
+ token: str,
253
+ db: AsyncSession,
254
+ ) -> bool:
255
+ token_hash = RefreshToken.hash_token(token)
256
+
257
+ result = await db.execute(
258
+ select(RefreshToken).where(RefreshToken.token_hash == token_hash)
259
+ )
260
+ refresh_token = result.scalar_one_or_none()
261
+
262
+ if not refresh_token:
263
+ return False
264
+
265
+ refresh_token.revoked = True
266
+ await db.commit()
267
+ return True
268
+
269
+ async def revoke_user_refresh_tokens(
270
+ self,
271
+ user_id: uuid.UUID,
272
+ db: Optional[AsyncSession] = None,
273
+ ) -> int:
274
+ """Revocar TODOS los refresh tokens de un usuario (logout everywhere)."""
275
+ if db is None:
276
+ async for session in get_db():
277
+ return await self._revoke_user_refresh_tokens_internal(user_id, session)
278
+ return await self._revoke_user_refresh_tokens_internal(user_id, db)
279
+
280
+ async def _revoke_user_refresh_tokens_internal(
281
+ self,
282
+ user_id: uuid.UUID,
283
+ db: AsyncSession,
284
+ ) -> int:
285
+ result = await db.execute(
286
+ select(RefreshToken).where(
287
+ RefreshToken.user_id == user_id,
288
+ RefreshToken.revoked == False,
289
+ )
290
+ )
291
+ tokens = result.scalars().all()
292
+
293
+ for token in tokens:
294
+ token.revoked = True
295
+
296
+ await db.commit()
297
+ return len(tokens)
298
+
299
+ async def create_user(self, user_create, safe: bool = False, request: Request | None = None):
300
+ """Override to validate password strength on registration."""
301
+ # Validate password strength
302
+ self.validate_password(user_create.password)
303
+
304
+ # Call parent create_user
305
+ return await super().create_user(user_create, safe, request)
306
+
307
+ async def update_user(self, user_update, user, safe: bool = False, request: Request | None = None):
308
+ """Override to validate password on update."""
309
+ if hasattr(user_update, 'password') and user_update.password:
310
+ self.validate_password(user_update.password)
311
+ return await super().update_user(user_update, user, safe, request)
312
+
313
+
314
+ async def get_user_manager(user_db=Depends(get_user_db)):
315
+ yield UserManager(user_db)
app/auth/models.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import hashlib
3
+ import secrets
4
+ import json
5
+ from datetime import datetime, timedelta
6
+ from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTableUUID
7
+ from fastapi_users_db_sqlalchemy.generics import GUID
8
+ from sqlalchemy import Column, String, Integer, DateTime, Boolean, ForeignKey, Index, Text
9
+ from sqlalchemy.sql import func
10
+ from app.database import Base
11
+ # Use versioned encryption from utils
12
+ from app.utils.encryption import encrypt_mfa, decrypt_mfa, encrypt_backup_codes, decrypt_backup_codes
13
+
14
+
15
+ class User(SQLAlchemyBaseUserTableUUID, Base):
16
+ __tablename__ = "users"
17
+
18
+ full_name = Column(String(255), nullable=True)
19
+ credits = Column(Integer, default=1) # Limit to 1 free search
20
+ plan = Column(String(50), default="free")
21
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
22
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
23
+
24
+ # Account lockout
25
+ failed_login_attempts = Column(Integer, default=0, nullable=False)
26
+ locked_until = Column(DateTime(timezone=True), nullable=True)
27
+
28
+ # Email verification
29
+ is_verified = Column(Boolean, default=False, nullable=False)
30
+
31
+ # ─── MFA / 2FA (encrypted) ───
32
+ mfa_enabled = Column(Boolean, default=False, nullable=False)
33
+ mfa_secret = Column(Text, nullable=True) # Encrypted TOTP secret (Fernet)
34
+ mfa_backup_codes = Column(Text, nullable=True) # Encrypted JSON array of backup codes
35
+ mfa_verified_at = Column(DateTime(timezone=True), nullable=True)
36
+
37
+ # OAuth / Social Login
38
+ oauth_provider = Column(String(50), nullable=True) # google, microsoft, github
39
+ oauth_provider_id = Column(String(255), nullable=True)
40
+
41
+ # Password strength
42
+ password_strength_score = Column(Integer, nullable=True) # zxcvbn score 0-4
43
+
44
+ @property
45
+ def mfa_secret_decrypted(self) -> str | None:
46
+ """Get decrypted MFA secret for TOTP verification (v1 or v2)."""
47
+ if self.mfa_secret:
48
+ from app.utils.encryption import safe_decrypt_mfa
49
+ return safe_decrypt_mfa(self.mfa_secret)
50
+ return None
51
+
52
+ @mfa_secret_decrypted.setter
53
+ def mfa_secret_decrypted(self, value: str | None):
54
+ """Set encrypted MFA secret (always writes v2)."""
55
+ from app.utils.encryption import encrypt_mfa
56
+ self.mfa_secret = encrypt_mfa(value) if value else None
57
+
58
+ @property
59
+ def mfa_backup_codes_decrypted(self) -> list[str]:
60
+ """Get decrypted backup codes list (v1 or v2)."""
61
+ if self.mfa_backup_codes:
62
+ from app.utils.encryption import safe_decrypt_backup_codes
63
+ return safe_decrypt_backup_codes(self.mfa_backup_codes)
64
+ return []
65
+
66
+ @mfa_backup_codes_decrypted.setter
67
+ def mfa_backup_codes_decrypted(self, value: list[str] | None):
68
+ """Set encrypted backup codes (always writes v2)."""
69
+ from app.utils.encryption import encrypt_backup_codes
70
+ self.mfa_backup_codes = encrypt_backup_codes(value) if value else None
71
+
72
+
73
+ class LoginHistory(Base):
74
+ __tablename__ = "login_history"
75
+
76
+ id = Column(Integer, primary_key=True, autoincrement=True)
77
+ user_id = Column(GUID, ForeignKey("users.id"), nullable=True)
78
+ email = Column(String(255), nullable=False)
79
+ success = Column(Boolean, default=False)
80
+ ip_address = Column(String(45), nullable=True)
81
+ user_agent = Column(String(500), nullable=True)
82
+ failure_reason = Column(String(255), nullable=True)
83
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
84
+
85
+
86
+ class RefreshToken(Base):
87
+ """Refresh tokens para rotación segura (HttpOnly cookie)."""
88
+ __tablename__ = "refresh_tokens"
89
+
90
+ id = Column(Integer, primary_key=True, autoincrement=True)
91
+ user_id = Column(GUID, ForeignKey("users.id"), nullable=False, index=True)
92
+ token_hash = Column(String(64), nullable=False, unique=True, index=True) # SHA-256
93
+ expires_at = Column(DateTime(timezone=True), nullable=False)
94
+ revoked = Column(Boolean, default=False, nullable=False)
95
+ user_agent = Column(String(500), nullable=True)
96
+ ip_address = Column(String(45), nullable=True)
97
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
98
+
99
+ __table_args__ = (
100
+ Index("ix_refresh_tokens_user_revoked", "user_id", "revoked"),
101
+ )
102
+
103
+ @staticmethod
104
+ def hash_token(token: str) -> str:
105
+ """Hash del token para almacenamiento seguro."""
106
+ return hashlib.sha256(token.encode()).hexdigest()
107
+
108
+ @staticmethod
109
+ def generate_token() -> str:
110
+ """Generar token criptográficamente seguro."""
111
+ return secrets.token_urlsafe(32)
112
+
113
+ def verify(self, token: str) -> bool:
114
+ """Verificar token contra hash almacenado."""
115
+ return self.token_hash == self.hash_token(token) and not self.revoked and self.expires_at > datetime.utcnow()
app/auth/router.py ADDED
@@ -0,0 +1,661 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import json
3
+ import secrets
4
+ from typing import Optional
5
+ from datetime import datetime
6
+ from fastapi import APIRouter, Depends, HTTPException, Response, Request, Cookie
7
+ from fastapi_users import FastAPIUsers, exceptions as fu_exceptions
8
+ from fastapi_users.jwt import generate_jwt
9
+ from app.auth.models import User, LoginHistory
10
+ from app.auth.manager import get_user_manager, UserManager
11
+ from app.auth.config import auth_backend, cookie_auth_backend
12
+ from app.auth.schemas import UserRead, UserCreate, UserUpdate
13
+ from app.database import get_db
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+ from pydantic import BaseModel
16
+ import traceback
17
+ import pyotp
18
+ import qrcode
19
+ import io
20
+ import base64
21
+ from app.config import get_settings
22
+ from app.utils.security import mask_email
23
+ from app.auth.models import User as UserModel
24
+
25
+ settings = get_settings()
26
+
27
+ # Incluir AMBOS backends para dual auth (header + cookie)
28
+ fastapi_users = FastAPIUsers[User, uuid.UUID](
29
+ get_user_manager,
30
+ [auth_backend, cookie_auth_backend],
31
+ )
32
+
33
+ current_active_user = fastapi_users.current_user(active=True)
34
+
35
+ router = APIRouter(prefix="/auth", tags=["auth"])
36
+
37
+ # Auth router con AMBOS backends
38
+ router.include_router(
39
+ fastapi_users.get_auth_router(auth_backend, requires_verification=False),
40
+ prefix="/jwt",
41
+ )
42
+
43
+ router.include_router(
44
+ fastapi_users.get_auth_router(cookie_auth_backend, requires_verification=False),
45
+ prefix="/jwt",
46
+ )
47
+
48
+ router.include_router(
49
+ fastapi_users.get_register_router(UserRead, UserCreate),
50
+ )
51
+
52
+ router.include_router(
53
+ fastapi_users.get_users_router(UserRead, UserUpdate),
54
+ prefix="/users",
55
+ )
56
+
57
+
58
+ class ForgotPasswordRequest(BaseModel):
59
+ email: str
60
+
61
+
62
+ class ResetPasswordRequest(BaseModel):
63
+ token: str
64
+ password: str
65
+
66
+
67
+ @router.post("/forgot-password")
68
+ async def forgot_password(
69
+ body: ForgotPasswordRequest,
70
+ manager: UserManager = Depends(get_user_manager),
71
+ ):
72
+ try:
73
+ user = await manager.get_by_email(body.email)
74
+ except fu_exceptions.UserNotExists:
75
+ return {"message": "Si el email está registrado, se envió un enlace de recuperación."}
76
+
77
+ token_data = {
78
+ "sub": str(user.id),
79
+ "password_fgpt": manager.password_helper.hash(user.hashed_password),
80
+ "aud": manager.reset_password_token_audience,
81
+ }
82
+ token = generate_jwt(
83
+ token_data,
84
+ manager.reset_password_token_secret,
85
+ manager.reset_password_token_lifetime_seconds,
86
+ )
87
+ await manager.on_after_forgot_password(user, token, None)
88
+
89
+ return {
90
+ "message": "Si el email está registrado, se envió un enlace de recuperación.",
91
+ }
92
+
93
+
94
+ @router.post("/reset-password")
95
+ async def reset_password(
96
+ body: ResetPasswordRequest,
97
+ manager: UserManager = Depends(get_user_manager),
98
+ ):
99
+ try:
100
+ user = await manager.reset_password(body.token, body.password)
101
+ except fu_exceptions.InvalidResetPasswordToken:
102
+ raise HTTPException(status_code=400, detail="Token inválido o expirado.")
103
+ except fu_exceptions.UserInactive:
104
+ raise HTTPException(status_code=400, detail="Usuario inactivo.")
105
+
106
+ await manager.on_after_reset_password(user, None)
107
+ return {"message": "Contraseña actualizada correctamente."}
108
+
109
+
110
+ # Custom login que setea cookie HttpOnly Y devuelve token en body (compatibilidad)
111
+ @router.post("/jwt/login")
112
+ async def jwt_login(
113
+ request: Request,
114
+ response: Response,
115
+ manager: UserManager = Depends(get_user_manager),
116
+ ):
117
+ try:
118
+ # Parse form data (OAuth2 password flow)
119
+ form = await request.form()
120
+ username = form.get("username")
121
+ password = form.get("password")
122
+
123
+ if not username or not password:
124
+ raise HTTPException(status_code=400, detail="username y password requeridos")
125
+
126
+ # Autenticar usuario usando OAuth2PasswordRequestForm
127
+ from fastapi.security import OAuth2PasswordRequestForm
128
+ credentials = OAuth2PasswordRequestForm(username=username, password=password)
129
+ user = await manager.authenticate(credentials)
130
+ if not user:
131
+ raise HTTPException(status_code=400, detail="Credenciales inválidas")
132
+ if not user.is_active:
133
+ raise HTTPException(status_code=400, detail="Usuario inactivo")
134
+
135
+ # Si MFA está habilitado, requerir desafío TOTP/backup code
136
+ if user.mfa_enabled:
137
+ # Generar token temporal de pre-autenticación (válido 5 min)
138
+ from fastapi_users.jwt import generate_jwt
139
+ from app.config import get_settings
140
+ settings = get_settings()
141
+ mfa_token_data = {
142
+ "sub": str(user.id),
143
+ "mfa_pending": True,
144
+ "aud": "mfa-challenge",
145
+ }
146
+ mfa_token = generate_jwt(
147
+ mfa_token_data,
148
+ settings.secret_key,
149
+ 300, # 5 minutos
150
+ )
151
+ return {
152
+ "mfa_required": True,
153
+ "mfa_token": mfa_token,
154
+ "message": "Introduce tu código TOTP o código de respaldo",
155
+ }
156
+
157
+ # Generar access token
158
+ jwt_strategy = auth_backend.get_strategy()
159
+ access_token = await jwt_strategy.write_token(user)
160
+
161
+ # Crear refresh token (rotación)
162
+ from app.database import get_db
163
+ async for db in get_db():
164
+ refresh_token = await manager.create_refresh_token(user, request, db)
165
+
166
+ # Setear cookie HttpOnly para access token
167
+ from app.config import get_settings
168
+ settings = get_settings()
169
+ cookie_max_age = settings.access_token_expire_minutes * 60
170
+ response.set_cookie(
171
+ key="cd_token",
172
+ value=access_token,
173
+ max_age=cookie_max_age,
174
+ httponly=True,
175
+ secure=not settings.debug,
176
+ samesite="lax",
177
+ path="/",
178
+ )
179
+
180
+ # Setear cookie HttpOnly para refresh token
181
+ refresh_max_age = settings.refresh_token_expire_days * 24 * 60 * 60
182
+ response.set_cookie(
183
+ key="cd_refresh_token",
184
+ value=refresh_token,
185
+ max_age=refresh_max_age,
186
+ httponly=True,
187
+ secure=not settings.debug,
188
+ samesite="lax",
189
+ path="/",
190
+ )
191
+
192
+ # También setear cookie de expiración para que el frontend pueda leerla
193
+ import time
194
+ response.set_cookie(
195
+ key="cd_token_expiry",
196
+ value=str(int(time.time() * 1000) + cookie_max_age * 1000),
197
+ max_age=cookie_max_age,
198
+ httponly=True,
199
+ secure=not settings.debug,
200
+ samesite="lax",
201
+ path="/",
202
+ )
203
+
204
+ # Devolver token en body para compatibilidad con clientes existentes
205
+ return {
206
+ "access_token": access_token,
207
+ "token_type": "bearer",
208
+ }
209
+ except HTTPException:
210
+ raise
211
+ except Exception as e:
212
+ import logging
213
+ logger = logging.getLogger("crowdata.auth")
214
+ logger.error(f"Error en jwt_login: {e}", exc_info=True)
215
+ raise HTTPException(status_code=500, detail=f"Error interno: {str(e)}")
216
+
217
+
218
+ # Refresh token endpoint - rota access token + nuevo refresh token
219
+ @router.post("/jwt/refresh")
220
+ async def jwt_refresh(
221
+ request: Request,
222
+ response: Response,
223
+ cd_refresh_token: Optional[str] = Cookie(None, alias="cd_refresh_token"),
224
+ manager: UserManager = Depends(get_user_manager),
225
+ ):
226
+ if not cd_refresh_token:
227
+ raise HTTPException(status_code=401, detail="Refresh token requerido")
228
+
229
+ # Verificar refresh token
230
+ from app.database import get_db
231
+ async for db in get_db():
232
+ user = await manager.verify_refresh_token(cd_refresh_token, db)
233
+
234
+ if not user:
235
+ raise HTTPException(status_code=401, detail="Refresh token inválido o expirado")
236
+
237
+ # Generar nuevo access token
238
+ jwt_strategy = auth_backend.get_strategy()
239
+ access_token = await jwt_strategy.write_token(user)
240
+
241
+ # Rotar refresh token (revocar viejo, crear nuevo)
242
+ from app.database import get_db
243
+ async for db in get_db():
244
+ new_refresh_token = await manager.create_refresh_token(user, request, db)
245
+
246
+ # Setear cookies
247
+ from app.config import get_settings
248
+ settings = get_settings()
249
+ cookie_max_age = settings.access_token_expire_minutes * 60
250
+ refresh_max_age = settings.refresh_token_expire_days * 24 * 60 * 60
251
+
252
+ response.set_cookie(
253
+ key="cd_token",
254
+ value=access_token,
255
+ max_age=cookie_max_age,
256
+ httponly=True,
257
+ secure=not settings.debug,
258
+ samesite="lax",
259
+ path="/",
260
+ )
261
+ response.set_cookie(
262
+ key="cd_refresh_token",
263
+ value=new_refresh_token,
264
+ max_age=refresh_max_age,
265
+ httponly=True,
266
+ secure=not settings.debug,
267
+ samesite="lax",
268
+ path="/",
269
+ )
270
+ import time
271
+ response.set_cookie(
272
+ key="cd_token_expiry",
273
+ value=str(int(time.time() * 1000) + cookie_max_age * 1000),
274
+ max_age=cookie_max_age,
275
+ httponly=True,
276
+ secure=not settings.debug,
277
+ samesite="lax",
278
+ path="/",
279
+ )
280
+
281
+ return {
282
+ "access_token": access_token,
283
+ "token_type": "bearer",
284
+ }
285
+
286
+
287
+ # Logout: limpiar cookies
288
+ @router.post("/jwt/logout")
289
+ async def jwt_logout(
290
+ response: Response,
291
+ cd_refresh_token: Optional[str] = Cookie(None, alias="cd_refresh_token"),
292
+ manager: UserManager = Depends(get_user_manager),
293
+ ):
294
+ # Revocar refresh token en BD
295
+ if cd_refresh_token:
296
+ from app.database import get_db
297
+ async for db in get_db():
298
+ await manager.revoke_refresh_token(cd_refresh_token, db)
299
+
300
+ response.delete_cookie("cd_token", path="/", httponly=True, secure=True, samesite="lax")
301
+ response.delete_cookie("cd_refresh_token", path="/", httponly=True, secure=True, samesite="lax")
302
+ response.delete_cookie("cd_token_expiry", path="/", httponly=True, secure=True, samesite="lax")
303
+ return {"message": "Sesión cerrada"}
304
+
305
+
306
+ # ─── JWKS Endpoint para claves públicas (RFC 7517) ───
307
+ @router.get("/.well-known/jwks.json", tags=["auth"])
308
+ async def jwks():
309
+ """JSON Web Key Set - expone claves públicas para verificación RS256."""
310
+ from app.config import get_settings
311
+ settings = get_settings()
312
+
313
+ if not settings.jwt_public_key:
314
+ # Fallback: leer archivo
315
+ import os
316
+ key_path = os.path.join(os.path.dirname(__file__), '..', '..', 'public_key.pem')
317
+ if os.path.exists(key_path):
318
+ with open(key_path, 'r') as f:
319
+ public_key_pem = f.read()
320
+ else:
321
+ raise HTTPException(status_code=503, detail="Clave pública no configurada")
322
+ else:
323
+ public_key_pem = settings.jwt_public_key
324
+
325
+ # Convertir PEM a JWK
326
+ from jwt.algorithms import RSAAlgorithm
327
+ public_key = RSAAlgorithm.from_jwk(public_key_pem)
328
+ numbers = public_key.public_numbers()
329
+
330
+ # Base64url encode sin padding
331
+ import base64
332
+ def b64url_encode(data: bytes) -> str:
333
+ return base64.urlsafe_b64encode(data).decode().rstrip('=')
334
+
335
+ n = b64url_encode(numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, 'big'))
336
+ e = b64url_encode(numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, 'big'))
337
+
338
+ return {
339
+ "keys": [
340
+ {
341
+ "kty": "RSA",
342
+ "use": "sig",
343
+ "alg": "RS256",
344
+ "kid": settings.jwt_key_id,
345
+ "n": n,
346
+ "e": e,
347
+ }
348
+ ]
349
+ }
350
+
351
+
352
+ # ════════════════════════════════════════════════════════════════════════════
353
+ # MFA / 2FA (TOTP) Endpoints
354
+ # ════════════════════════════════════════════════════════════════════════════
355
+
356
+ class MFASetupRequest(BaseModel):
357
+ password: str # Confirmar contraseña actual
358
+
359
+
360
+ class MFAVerifyRequest(BaseModel):
361
+ code: str # Código TOTP de 6 dígitos
362
+
363
+
364
+ class MFADisableRequest(BaseModel):
365
+ password: str
366
+ code: str
367
+
368
+
369
+ @router.post("/mfa/setup", dependencies=[Depends(current_active_user)])
370
+ async def mfa_setup(
371
+ response: Response,
372
+ request: Request,
373
+ body: MFASetupRequest,
374
+ user: User = Depends(current_active_user),
375
+ manager: UserManager = Depends(get_user_manager),
376
+ ):
377
+ """
378
+ Iniciar configuración de MFA.
379
+ Genera secreto TOTP y devuelve QR code (base64) + secret para app autenticadora.
380
+ Requiere contraseña actual para confirmar identidad.
381
+ """
382
+ # Verificar contraseña
383
+ if not manager.password_helper.verify(body.password, user.hashed_password):
384
+ raise HTTPException(status_code=400, detail="Contraseña incorrecta")
385
+
386
+ if user.mfa_enabled:
387
+ raise HTTPException(status_code=400, detail="MFA ya está habilitado")
388
+
389
+ import pyotp
390
+ import qrcode
391
+ import io
392
+ import base64
393
+
394
+ # Generar secreto único
395
+ secret = pyotp.random_base32()
396
+
397
+ # Generar URI para QR code (compatible con Google Authenticator, Authy, etc.)
398
+ totp_uri = pyotp.totp.TOTP(secret).provisioning_uri(
399
+ name=user.email,
400
+ issuer_name="CrowData",
401
+ )
402
+
403
+ # Generar QR code como base64
404
+ qr = qrcode.QRCode(version=1, box_size=10, border=5)
405
+ qr.add_data(totp_uri)
406
+ qr.make(fit=True)
407
+ img = qr.make_image(fill_color="black", back_color="white")
408
+
409
+ buf = io.BytesIO()
410
+ img.save(buf, format='PNG')
411
+ qr_base64 = base64.b64encode(buf.getvalue()).decode()
412
+
413
+ # Guardar secreto temporal (no activar hasta verificar) - using encrypted property
414
+ user.mfa_secret_decrypted = secret
415
+ user.mfa_backup_codes_decrypted = []
416
+ from app.database import get_db
417
+ async for db in get_db():
418
+ await db.commit()
419
+
420
+ return {
421
+ "secret": secret,
422
+ "qr_code": f"data:image/png;base64,{qr_base64}",
423
+ "uri": totp_uri,
424
+ "message": "Escanea el QR con tu app autenticadora (Google Authenticator, Authy, 1Password, etc.) y luego usa /mfa/verify para activar."
425
+ }
426
+
427
+
428
+ @router.post("/mfa/verify", dependencies=[Depends(current_active_user)])
429
+ async def mfa_verify(
430
+ body: MFAVerifyRequest,
431
+ user: User = Depends(current_active_user),
432
+ manager: UserManager = Depends(get_user_manager),
433
+ ):
434
+ """
435
+ Verificar código TOTP y activar MFA.
436
+ Genera códigos de respaldo (backup codes) al activar.
437
+ """
438
+ if not user.mfa_secret_decrypted:
439
+ raise HTTPException(status_code=400, detail="MFA no iniciado. Usa /mfa/setup primero.")
440
+
441
+ if user.mfa_enabled:
442
+ raise HTTPException(status_code=400, detail="MFA ya está habilitado")
443
+
444
+ import pyotp
445
+
446
+ totp = pyotp.TOTP(user.mfa_secret_decrypted)
447
+ if not totp.verify(body.code, valid_window=1):
448
+ raise HTTPException(status_code=400, detail="Código inválido o expirado")
449
+
450
+ # Generar backup codes (8 códigos de 8 chars cada uno)
451
+ import secrets
452
+ backup_codes = [secrets.token_urlsafe(6) for _ in range(8)]
453
+
454
+ user.mfa_enabled = True
455
+ user.mfa_backup_codes_decrypted = backup_codes
456
+ user.mfa_verified_at = datetime.utcnow()
457
+
458
+ from app.database import get_db
459
+ async for db in get_db():
460
+ await db.commit()
461
+
462
+ return {
463
+ "message": "MFA activado correctamente",
464
+ "backup_codes": backup_codes,
465
+ "warning": "Guarda estos códigos de respaldo en un lugar seguro. Cada uno se puede usar una sola vez."
466
+ }
467
+
468
+
469
+ @router.post("/mfa/disable", dependencies=[Depends(current_active_user)])
470
+ async def mfa_disable(
471
+ body: MFADisableRequest,
472
+ user: User = Depends(current_active_user),
473
+ manager: UserManager = Depends(get_user_manager),
474
+ ):
475
+ """
476
+ Desactivar MFA.
477
+ Requiere contraseña + código TOTP actual (o backup code).
478
+ """
479
+ if not user.mfa_enabled:
480
+ raise HTTPException(status_code=400, detail="MFA no está habilitado")
481
+
482
+ # Verificar contraseña
483
+ if not manager.password_helper.verify(body.password, user.hashed_password):
484
+ raise HTTPException(status_code=400, detail="Contraseña incorrecta")
485
+
486
+ # Verificar código (TOTP o backup code)
487
+ import pyotp
488
+
489
+ valid = False
490
+ totp = pyotp.TOTP(user.mfa_secret_decrypted)
491
+
492
+ if totp.verify(body.code, valid_window=1):
493
+ valid = True
494
+ elif body.code in user.mfa_backup_codes_decrypted:
495
+ # Es un backup code - consumirlo
496
+ codes = user.mfa_backup_codes_decrypted
497
+ codes.remove(body.code)
498
+ user.mfa_backup_codes_decrypted = codes
499
+ valid = True
500
+
501
+ if not valid:
502
+ raise HTTPException(status_code=400, detail="Código inválido")
503
+
504
+ # Desactivar MFA
505
+ user.mfa_enabled = False
506
+ user.mfa_secret_decrypted = None
507
+ user.mfa_backup_codes_decrypted = []
508
+ user.mfa_verified_at = None
509
+
510
+ from app.database import get_db
511
+ async for db in get_db():
512
+ await db.commit()
513
+
514
+ return {"message": "MFA desactivado correctamente"}
515
+
516
+
517
+ @router.post("/mfa/backup-codes", dependencies=[Depends(current_active_user)])
518
+ async def mfa_regenerate_backup_codes(
519
+ user: User = Depends(current_active_user),
520
+ manager: UserManager = Depends(get_user_manager),
521
+ ):
522
+ """
523
+ Regenerar códigos de respaldo (invalida los anteriores).
524
+ """
525
+ if not user.mfa_enabled:
526
+ raise HTTPException(status_code=400, detail="MFA no está habilitado")
527
+
528
+ import secrets
529
+ backup_codes = [secrets.token_urlsafe(6) for _ in range(8)]
530
+
531
+ user.mfa_backup_codes_decrypted = backup_codes
532
+ from app.database import get_db
533
+ async for db in get_db():
534
+ await db.commit()
535
+
536
+ return {
537
+ "backup_codes": backup_codes,
538
+ "warning": "Los códigos anteriores han sido invalidados. Guarda los nuevos en un lugar seguro."
539
+ }
540
+
541
+
542
+ @router.get("/mfa/status", dependencies=[Depends(current_active_user)])
543
+ async def mfa_status(user: User = Depends(current_active_user)):
544
+ """Estado actual de MFA del usuario."""
545
+ return {
546
+ "mfa_enabled": user.mfa_enabled,
547
+ "mfa_verified_at": user.mfa_verified_at,
548
+ "backup_codes_remaining": len(user.mfa_backup_codes_decrypted),
549
+ }
550
+
551
+
552
+ class MFAChallengeRequest(BaseModel):
553
+ mfa_token: str # Token temporal del login inicial
554
+ code: str # Código TOTP o backup code
555
+
556
+
557
+ @router.post("/mfa/challenge")
558
+ async def mfa_challenge(
559
+ body: MFAChallengeRequest,
560
+ response: Response,
561
+ manager: UserManager = Depends(get_user_manager),
562
+ ):
563
+ """
564
+ Verificar código TOTP/backup code durante login con MFA habilitado.
565
+ Recibe el token temporal (mfa_token) del login inicial + código.
566
+ Si es válido, setea cookies y devuelve access_token.
567
+ """
568
+ # Verificar token temporal MFA
569
+ from app.config import get_settings
570
+ settings = get_settings()
571
+ try:
572
+ import jwt as pyjwt
573
+ payload = pyjwt.decode(
574
+ body.mfa_token,
575
+ settings.secret_key,
576
+ algorithms=["HS256"],
577
+ audience="mfa-challenge",
578
+ )
579
+ except pyjwt.InvalidTokenError:
580
+ raise HTTPException(status_code=401, detail="Token MFA inválido o expirado")
581
+
582
+ if not payload.get("mfa_pending"):
583
+ raise HTTPException(status_code=401, detail="Token MFA inválido")
584
+
585
+ user_id = payload.get("sub")
586
+ if not user_id:
587
+ raise HTTPException(status_code=401, detail="Token MFA inválido")
588
+
589
+ import uuid
590
+ user = await manager.get(uuid.UUID(user_id))
591
+ if not user or not user.is_active:
592
+ raise HTTPException(status_code=401, detail="Usuario no encontrado o inactivo")
593
+
594
+ if not user.mfa_enabled:
595
+ raise HTTPException(status_code=400, detail="MFA no está habilitado para este usuario")
596
+
597
+ # Verificar código TOTP
598
+ import pyotp
599
+ totp = pyotp.TOTP(user.mfa_secret_decrypted)
600
+
601
+ code_valid = False
602
+ if totp.verify(body.code, valid_window=1):
603
+ code_valid = True
604
+ elif body.code in user.mfa_backup_codes_decrypted:
605
+ # Es un backup code - consumirlo
606
+ codes = user.mfa_backup_codes_decrypted
607
+ codes.remove(body.code)
608
+ user.mfa_backup_codes_decrypted = codes
609
+ code_valid = True
610
+
611
+ if not code_valid:
612
+ raise HTTPException(status_code=400, detail="Código inválido o expirado")
613
+
614
+ # Código válido - generar tokens normales
615
+ jwt_strategy = auth_backend.get_strategy()
616
+ access_token = await jwt_strategy.write_token(user)
617
+
618
+ # Crear refresh token
619
+ from app.database import get_db
620
+ from starlette.requests import Request
621
+ async for db in get_db():
622
+ refresh_token = await manager.create_refresh_token(user, Request({"type": "http"}), db)
623
+
624
+ # Setear cookies HttpOnly
625
+ cookie_max_age = settings.access_token_expire_minutes * 60
626
+ refresh_max_age = settings.refresh_token_expire_days * 24 * 60 * 60
627
+
628
+ response.set_cookie(
629
+ key="cd_token",
630
+ value=access_token,
631
+ max_age=cookie_max_age,
632
+ httponly=True,
633
+ secure=not settings.debug,
634
+ samesite="lax",
635
+ path="/",
636
+ )
637
+ response.set_cookie(
638
+ key="cd_refresh_token",
639
+ value=refresh_token,
640
+ max_age=refresh_max_age,
641
+ httponly=True,
642
+ secure=not settings.debug,
643
+ samesite="lax",
644
+ path="/",
645
+ )
646
+ import time
647
+ response.set_cookie(
648
+ key="cd_token_expiry",
649
+ value=str(int(time.time() * 1000) + cookie_max_age * 1000),
650
+ max_age=cookie_max_age,
651
+ httponly=True,
652
+ secure=not settings.debug,
653
+ samesite="lax",
654
+ path="/",
655
+ )
656
+
657
+ return {
658
+ "access_token": access_token,
659
+ "token_type": "bearer",
660
+ "message": "Autenticación MFA completada"
661
+ }
app/auth/schemas.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from typing import Optional
3
+ from fastapi_users import schemas
4
+
5
+ class UserRead(schemas.BaseUser[uuid.UUID]):
6
+ full_name: Optional[str] = None
7
+ credits: int
8
+ plan: str
9
+
10
+ class UserCreate(schemas.BaseUserCreate):
11
+ full_name: Optional[str] = None
12
+
13
+ class UserUpdate(schemas.BaseUserUpdate):
14
+ full_name: Optional[str] = None
app/cache/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ 
app/cache/ddjj_pep.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:904c746875746b6c14bd3d44427bd4081e7c3eb51970b53389f56d2dd354c2b4
3
+ size 29406143
app/cache/redis_client.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Redis Client Pool — CrowData
3
+ Centralized Redis connection management with in-memory LRU fallback.
4
+ """
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import time
9
+ from collections import OrderedDict
10
+ from typing import Optional
11
+ import redis.asyncio as aioredis
12
+ from app.config import get_settings
13
+
14
+ logger = logging.getLogger(__name__)
15
+ settings = get_settings()
16
+
17
+ # Global connection pools
18
+ _redis_client = None
19
+ _redis_is_available = True
20
+ _reconnect_task = None
21
+
22
+ # In-memory LRU cache fallback
23
+ class LRUCache:
24
+ def __init__(self, maxsize: int = 5000):
25
+ self.maxsize = maxsize
26
+ self._cache = OrderedDict()
27
+ self._ttls = {}
28
+ self._lock = asyncio.Lock()
29
+
30
+ async def get(self, key: str):
31
+ async with self._lock:
32
+ if key not in self._cache:
33
+ return None
34
+ expire_at = self._ttls.get(key, 0)
35
+ if expire_at and expire_at < time.time():
36
+ # Expired
37
+ self._cache.pop(key, None)
38
+ self._ttls.pop(key, None)
39
+ return None
40
+ # Move to end (most recently used)
41
+ value = self._cache.pop(key)
42
+ self._cache[key] = value
43
+ return value
44
+
45
+ async def set(self, key: str, value: dict, ttl: int):
46
+ async with self._lock:
47
+ # Evict if at maxsize
48
+ if len(self._cache) >= self.maxsize and key not in self._cache:
49
+ self._cache.popitem(last=False) # Remove LRU
50
+ self._cache[key] = value
51
+ self._ttls[key] = time.time() + ttl if ttl > 0 else 0
52
+
53
+ async def delete(self, key: str):
54
+ async with self._lock:
55
+ self._cache.pop(key, None)
56
+ self._ttls.pop(key, None)
57
+
58
+ async def cleanup_expired(self):
59
+ """Remove expired entries. Called periodically."""
60
+ async with self._lock:
61
+ now = time.time()
62
+ expired = [k for k, exp in self._ttls.items() if exp and exp < now]
63
+ for k in expired:
64
+ self._cache.pop(k, None)
65
+ self._ttls.pop(k, None)
66
+
67
+ def __len__(self):
68
+ return len(self._cache)
69
+
70
+
71
+ _in_memory_cache = LRUCache(maxsize=5000)
72
+
73
+ _redis_client = None
74
+ _redis_is_available = True
75
+ _reconnect_task = None
76
+
77
+
78
+ async def get_redis():
79
+ global _redis_client
80
+ if _redis_client is None:
81
+ import redis.asyncio as aioredis
82
+ from app.config import get_settings
83
+ settings = get_settings()
84
+ _redis_client = await aioredis.from_url(
85
+ settings.redis_url,
86
+ encoding="utf-8",
87
+ decode_responses=True,
88
+ max_connections=settings.redis_max_connections or 20,
89
+ socket_keepalive=True,
90
+ socket_connect_timeout=5,
91
+ socket_timeout=5,
92
+ retry_on_timeout=True,
93
+ )
94
+ return _redis_client
95
+
96
+
97
+ async def _try_reconnect():
98
+ """Background task to attempt Redis reconnection."""
99
+ global _redis_is_available, _reconnect_task
100
+ while True:
101
+ await asyncio.sleep(30) # Try every 30 seconds
102
+ if not _redis_is_available:
103
+ try:
104
+ r = await get_redis()
105
+ await r.ping()
106
+ _redis_is_available = True
107
+ logger.info("Redis reconnected successfully")
108
+ except Exception:
109
+ pass
110
+
111
+
112
+ async def _start_reconnect_task():
113
+ global _reconnect_task
114
+ if _reconnect_task is None or _reconnect_task.done():
115
+ _reconnect_task = asyncio.create_task(_try_reconnect())
116
+
117
+
118
+ async def cache_get(key: str):
119
+ global _redis_is_available
120
+ if _redis_is_available:
121
+ try:
122
+ r = await get_redis()
123
+ data = await r.get(key)
124
+ if data:
125
+ return json.loads(data)
126
+ except Exception as e:
127
+ logger.warning(f"Redis GET error for key {key}: {e}. Falling back to in-memory cache.")
128
+ _redis_is_available = False
129
+ # Start background reconnection
130
+ asyncio.create_task(_try_reconnect())
131
+
132
+ # In-memory fallback
133
+ return await _in_memory_cache.get(key)
134
+
135
+
136
+ async def cache_set(key: str, value: dict, ttl: int = None):
137
+ global _redis_is_available
138
+ ttl = ttl or 86400
139
+ if _redis_is_available:
140
+ try:
141
+ r = await get_redis()
142
+ await r.setex(key, ttl, json.dumps(value, ensure_ascii=False))
143
+ return
144
+ except Exception as e:
145
+ logger.warning(f"Redis SET error for key {key}: {e}. Saving in memory.")
146
+ _redis_is_available = False
147
+ # Start background reconnection
148
+ asyncio.create_task(_try_reconnect())
149
+
150
+ # In-memory LRU fallback
151
+ await _in_memory_cache.set(key, value, ttl=60)
152
+
153
+
154
+ async def cache_delete(key: str):
155
+ global _redis_is_available
156
+ if _redis_is_available:
157
+ try:
158
+ r = await get_redis()
159
+ await r.delete(key)
160
+ except Exception as e:
161
+ logger.warning(f"Redis DELETE error for key {key}: {e}.")
162
+ _redis_is_available = False
163
+ asyncio.create_task(_try_reconnect())
164
+
165
+ # In-memory fallback
166
+ await _in_memory_cache.delete(key)
167
+
168
+
169
+ async def cleanup_expired():
170
+ """Periodic cleanup of expired in-memory cache entries."""
171
+ await _in_memory_cache.cleanup_expired()
172
+
173
+
174
+ async def _start_reconnect_task():
175
+ """Start the background reconnection task."""
176
+ pass # Task is already started at module level
177
+
178
+
179
+ # Module-level initialization - start background tasks
180
+ try:
181
+ loop = asyncio.get_running_loop()
182
+ loop.create_task(_try_reconnect())
183
+ except RuntimeError:
184
+ # No event loop running, will be created later
185
+ pass
app/config.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ from pydantic_settings import BaseSettings
4
+ from functools import lru_cache
5
+ from pydantic import field_validator
6
+
7
+ _backend_dir = str(Path(__file__).resolve().parent.parent)
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ # App
12
+ app_name: str = "CrowData API"
13
+ environment: str = "development"
14
+ debug: bool = False # Default: False por seguridad. Usar ENVIRONMENT=development para debug.
15
+
16
+ # Database
17
+ database_url: str = ""
18
+
19
+ # Redis
20
+ redis_url: str = "redis://localhost:6379/0"
21
+
22
+ # Security — MUST be set in .env
23
+ secret_key: str = ""
24
+ reset_password_token_secret: str = "" # Separate secret for password reset tokens
25
+ verification_token_secret: str = "" # Separate secret for email verification tokens
26
+
27
+ # MFA Encryption Key (for encrypting TOTP secrets and backup codes in DB)
28
+ mfa_encryption_key: str = "" # Fernet key (32 bytes base64)
29
+
30
+ # JWT RS256 (asymmetric) — new keys for production
31
+ jwt_private_key: str = "" # RS256 private key (PEM)
32
+ jwt_public_key: str = "" # RS256 public key (PEM)
33
+ jwt_algorithm: str = "RS256" # New tokens signed with RS256
34
+ jwt_key_id: str = "key-1" # Key ID for rotation
35
+
36
+ # Legacy HS256 (symmetric) — for backward compatibility during transition
37
+ jwt_legacy_secret_key: str = "" # HS256 secret (same as secret_key)
38
+ jwt_legacy_algorithm: str = "HS256"
39
+ jwt_legacy_enabled: bool = False # Disabled by default in production
40
+
41
+ access_token_expire_minutes: int = 15 # 15 minutes (short-lived access token)
42
+ refresh_token_expire_days: int = 7 # 7 days (refresh token in HttpOnly cookie)
43
+
44
+ # Auth transport
45
+ use_cookie_auth: bool = True # Enable HttpOnly cookie auth
46
+ cookie_secure: bool = True # Secure cookie (HTTPS only)
47
+ cookie_samesite: str = "lax" # Lax for cross-site top-level nav, Strict for more security
48
+
49
+ # Cache
50
+ cache_ttl_seconds: int = 86400 # 24 hours
51
+
52
+ # Database pool
53
+ db_pool_size: int = 10
54
+ db_max_overflow: int = 20
55
+
56
+ # Scrapers
57
+ playwright_headless: bool = True
58
+ scraper_timeout_seconds: int = 30
59
+ proxy_url: str | None = None
60
+ proxy_list: list[str] = []
61
+ captcha_api_key: str | None = None # 2Captcha API key
62
+ nopecha_api_key: str | None = None # NopeCHA API key (reCAPTCHA v3 solver)
63
+ groq_api_key: str | None = None
64
+ searchapi_key: str | None = None
65
+ searchapi_keys: list[str] = []
66
+ ai_verification_enabled: bool = True
67
+
68
+ # Payments — default: empty string (must be set in .env for production)
69
+ mp_access_token: str = ""
70
+ mp_public_key: str = ""
71
+
72
+ # AFIP
73
+ afip_cuit_representada: str = ""
74
+ afip_cert_path: str = ""
75
+ afip_key_path: str = ""
76
+ afip_cache_file: str = ""
77
+
78
+ # Email / SMTP
79
+ smtp_host: str = "localhost"
80
+ smtp_port: int = 587
81
+ smtp_user: str = ""
82
+ smtp_password: str = ""
83
+ smtp_use_tls: bool = True
84
+ from_email: str = "crowsistemas@proton.me"
85
+ from_name: str = "CrowData"
86
+
87
+ # Allowed Origins (for CORS)
88
+ allowed_origins: str = ""
89
+
90
+ # ─── Validation ───
91
+ @field_validator("secret_key", "reset_password_token_secret", "verification_token_secret",
92
+ "mfa_encryption_key", "jwt_private_key", "jwt_public_key",
93
+ mode="before")
94
+ @classmethod
95
+ def _require_secrets_in_prod(cls, v, info):
96
+ # Solo validar en producción
97
+ if info.data.get("environment") == "production" and not v:
98
+ raise ValueError(f"{info.field_name} must be set in production")
99
+ return v
100
+
101
+ class Config:
102
+ env_file = os.path.join(_backend_dir, ".env")
103
+ env_file_encoding = "utf-8"
104
+ extra = "allow"
105
+
106
+
107
+ @lru_cache()
108
+ def get_settings() -> Settings:
109
+ return Settings()
app/database.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
2
+ from sqlalchemy.orm import DeclarativeBase
3
+ from app.config import get_settings
4
+
5
+ settings = get_settings()
6
+
7
+ # Normalizar la URL de base de datos para asegurar el uso del driver asincrónico asyncpg
8
+ db_url = settings.database_url
9
+ if db_url:
10
+ if db_url.startswith("postgresql://"):
11
+ db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1)
12
+ elif db_url.startswith("postgres://"):
13
+ db_url = db_url.replace("postgres://", "postgresql+asyncpg://", 1)
14
+
15
+ # asyncpg no soporta 'sslmode=require', requiere 'ssl=require'
16
+ if "sslmode=" in db_url:
17
+ db_url = db_url.replace("sslmode=require", "ssl=require")
18
+ db_url = db_url.replace("sslmode=disable", "ssl=disable")
19
+
20
+ engine_kwargs = {
21
+ "echo": settings.debug,
22
+ "pool_pre_ping": True,
23
+ }
24
+ if db_url.startswith("postgresql"):
25
+ engine_kwargs["pool_size"] = settings.db_pool_size
26
+ engine_kwargs["max_overflow"] = settings.db_max_overflow
27
+
28
+ engine = create_async_engine(
29
+ db_url,
30
+ **engine_kwargs
31
+ )
32
+
33
+ AsyncSessionLocal = async_sessionmaker(
34
+ engine,
35
+ class_=AsyncSession,
36
+ expire_on_commit=False,
37
+ )
38
+
39
+
40
+ class Base(DeclarativeBase):
41
+ pass
42
+
43
+
44
+ async def get_db():
45
+ async with AsyncSessionLocal() as session:
46
+ try:
47
+ yield session
48
+ await session.commit()
49
+ except Exception:
50
+ await session.rollback()
51
+ raise
52
+ finally:
53
+ await session.close()
54
+
55
+
56
+ async def init_db():
57
+ from app.auth.models import User # noqa: F401 - ensure models are loaded
58
+ from app.reports.models import ReportCache # noqa: F401
59
+
60
+ # Use Alembic for migrations instead of create_all
61
+ from alembic.config import Config
62
+ from alembic import command
63
+ import os
64
+
65
+ # Find alembic.ini (could be in backend dir)
66
+ alembic_ini = os.path.join(os.path.dirname(__file__), '..', 'alembic.ini')
67
+
68
+ try:
69
+ alembic_cfg = Config(alembic_ini)
70
+ # Use the existing database URL from settings
71
+ from app.config import get_settings
72
+ settings = get_settings()
73
+ # Normalize for synchronous alembic
74
+ db_url = settings.database_url
75
+ if db_url.startswith("sqlite+aiosqlite://"):
76
+ db_url = db_url.replace("sqlite+aiosqlite://", "sqlite://")
77
+ elif db_url.startswith("postgresql+asyncpg://"):
78
+ db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
79
+ alembic_cfg.set_main_option("sqlalchemy.url", db_url)
80
+ command.upgrade(alembic_cfg, "head")
81
+ except Exception as e:
82
+ import logging
83
+ logging.getLogger("app.database").warning(f"Error running migrations: {e}")
84
+ # Fallback to create_all for dev environments
85
+ async with engine.begin() as conn:
86
+ await conn.run_sync(Base.metadata.create_all)
app/main.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import sys
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.staticfiles import StaticFiles
6
+ from fastapi.responses import JSONResponse
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+ from contextlib import asynccontextmanager
9
+ import logging
10
+ import uuid
11
+
12
+ # Fix crítico para Windows: Playwright necesita ProactorEventLoop para subprocesses.
13
+ # SelectorEventLoop causa NotImplementedError en _make_subprocess_transport.
14
+ # Python 3.8+ usa WindowsProactorEventLoopPolicy por defecto — NO sobreescribir.
15
+ if sys.platform == "win32":
16
+ pass # Keep default ProactorEventLoop — Playwright requires it for subprocesses
17
+
18
+ from app.config import get_settings
19
+ from app.database import init_db
20
+ from app.auth.router import router as auth_router
21
+ from app.utils.logging_structured import CorrelationMiddleware, set_correlation_id, get_correlation_id
22
+
23
+ settings = get_settings()
24
+ # Use structured JSON logging in production, human-readable in dev
25
+ if settings.debug:
26
+ logging.basicConfig(
27
+ level=logging.DEBUG,
28
+ format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
29
+ )
30
+ else:
31
+ # Production: JSON structured logging
32
+ import logging.handlers
33
+ handler = logging.StreamHandler()
34
+ handler.setFormatter(logging.Formatter('%(message)s'))
35
+ root_logger = logging.getLogger()
36
+ root_logger.handlers = [handler]
37
+ root_logger.setLevel(logging.INFO)
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ @asynccontextmanager
43
+ async def lifespan(app: FastAPI):
44
+ # Validar settings críticos al iniciar
45
+ if not settings.secret_key:
46
+ logger.critical("SECRET_KEY no configurado en .env — la aplicación NO puede iniciar sin una clave secreta")
47
+ raise SystemExit(1)
48
+ if settings.mp_access_token and "TEST" in settings.mp_access_token:
49
+ logger.warning("MercadoPago usando access token de TEST — no procesará pagos reales")
50
+
51
+ logger.info("🚀 CrowData API iniciando...")
52
+ await init_db()
53
+ logger.info("✅ Base de datos inicializada")
54
+
55
+ # Iniciar Celery Beat para tareas programadas (monitoring, cleanup, etc.)
56
+ # En producción: celery -A app.tasks.celery_app beat -l info
57
+ # En desarrollo: usamos daemon simple
58
+ # Iniciar daemon de monitoring en desarrollo (sin Celery)
59
+ if settings.environment != "production":
60
+ try:
61
+ from app.tasks.monitoring import start_monitoring_daemon
62
+ monitor_task = asyncio.create_task(start_monitoring_daemon())
63
+ except (ImportError, AttributeError) as e:
64
+ logger.warning(f"⚠️ Monitoring daemon no disponible (Celery no instalado o función faltante): {e}")
65
+
66
+ yield
67
+
68
+ logger.info("🛑 Cancelando tareas de fondo...")
69
+ # Celery workers se gestionan externamente
70
+ logger.info("🛑 CrowData API cerrando...")
71
+
72
+
73
+ app = FastAPI(
74
+ title="CrowData API",
75
+ description="""
76
+ ## API de Consulta de Datos Públicos Argentinos
77
+
78
+ CrowData consulta múltiples fuentes oficiales argentinas para generar informes completos de personas, empresas, vehículos y propiedades.
79
+
80
+ ### Fuentes de datos
81
+ - **ARCA/AFIP** — Situación fiscal, IVA, Monotributo
82
+ - **BCRA** — Situación crediticia, cheques rechazados
83
+ - **IGJ** — Sociedades, directivos, sede social
84
+ - **Boletín Oficial** — Publicaciones oficiales
85
+ - **DNRPA** — Vehículos registrados
86
+ - **ANSES** — Aportes previsionales, obra social
87
+ - **INPI** — Marcas y patentes comerciales
88
+ - **Poder Judicial** — Causas judiciales federales y provinciales
89
+ - **Redes Sociales** — Perfiles públicos OSINT
90
+
91
+ ### Autenticación
92
+ Todos los endpoints requieren JWT token. Obtener token via `POST /api/auth/jwt/login`.
93
+
94
+ ### Rate Limits
95
+ - **Free**: 10 requests/min, 5 informes/día
96
+ - **Basic**: 60 requests/min, 50 informes/día
97
+ - **Pro**: 200 requests/min, ilimitado
98
+ """,
99
+ version="1.0.0",
100
+ lifespan=lifespan,
101
+ docs_url="/api/docs",
102
+ redoc_url="/api/redoc",
103
+ )
104
+
105
+
106
+ @app.exception_handler(Exception)
107
+ async def global_exception_handler(request: Request, exc: Exception):
108
+ """Captura errores no manejados y retorna respuesta segura."""
109
+ logger.error(f"Unhandled exception: {exc}", exc_info=True)
110
+ return JSONResponse(
111
+ status_code=500,
112
+ content={
113
+ "detail": "Ocurrió un error inesperado. Intentá nuevamente.",
114
+ "type": "internal_error",
115
+ },
116
+ )
117
+
118
+ # CORS — producción: solo dominios permitidos
119
+ ALLOWED_ORIGINS = [
120
+ "http://localhost:3000",
121
+ "http://localhost:5173",
122
+ "http://localhost:8080",
123
+ "https://crowdata.ar",
124
+ "https://www.crowdata.ar",
125
+ "https://crowdata.netlify.app",
126
+ "https://tomasdelpico-crowdata-api.hf.space",
127
+ ]
128
+ import os
129
+ env_origins = os.getenv("ALLOWED_ORIGINS")
130
+ if env_origins:
131
+ ALLOWED_ORIGINS.extend([origin.strip() for origin in env_origins.split(",") if origin.strip()])
132
+
133
+ app.add_middleware(
134
+ CORSMiddleware,
135
+ allow_origins=ALLOWED_ORIGINS,
136
+ allow_origin_regex=r"^https://(.*\.)?crowdata\.ar$",
137
+ allow_credentials=True,
138
+ allow_methods=["GET", "POST", "PUT", "DELETE"],
139
+ allow_headers=["Authorization", "Content-Type"],
140
+ )
141
+
142
+ # Correlation ID Middleware - must be after CORS but before other middleware
143
+ from app.utils.logging_structured import CorrelationMiddleware
144
+ app.add_middleware(CorrelationMiddleware)
145
+
146
+
147
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
148
+ """Agrega headers de seguridad a todas las respuestas."""
149
+
150
+ async def dispatch(self, request: Request, call_next):
151
+ response = await call_next(request)
152
+ response.headers["X-Content-Type-Options"] = "nosniff"
153
+ response.headers["X-Frame-Options"] = "DENY"
154
+ response.headers["X-XSS-Protection"] = "1; mode=block"
155
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
156
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
157
+ # HSTS solo en producción (HTTPS)
158
+ if settings.environment == "production":
159
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
160
+ return response
161
+
162
+
163
+ app.add_middleware(SecurityHeadersMiddleware)
164
+
165
+ # Rate Limiting
166
+ from app.middleware.rate_limit import RateLimitMiddleware
167
+ app.add_middleware(RateLimitMiddleware)
168
+
169
+ # Login History
170
+ from app.middleware.login_history import LoginHistoryMiddleware
171
+ app.add_middleware(LoginHistoryMiddleware)
172
+
173
+ # CSRF Protection (Double-submit cookie)
174
+ from app.middleware.csrf import get_csrf_middleware
175
+ from app.config import get_settings
176
+ settings = get_settings()
177
+ app.add_middleware(
178
+ get_csrf_middleware(
179
+ cookie_secure=not settings.debug,
180
+ cookie_samesite="lax",
181
+ excluded_paths=["/api/auth/jwt/login", "/api/auth/jwt/logout", "/api/auth/register", "/api/auth/forgot-password", "/api/auth/reset-password"],
182
+ )
183
+ )
184
+
185
+ from app.reports.router import router as reports_router
186
+ from app.reports.vehiculo_router import router as vehiculo_router
187
+ from app.payments.router import router as payments_router
188
+ from app.admin.router import router as admin_router
189
+
190
+ # Routers
191
+ app.include_router(auth_router, prefix="/api")
192
+ app.include_router(reports_router, prefix="/api")
193
+ app.include_router(vehiculo_router, prefix="/api")
194
+ app.include_router(payments_router, prefix="/api")
195
+ app.include_router(admin_router, prefix="/api")
196
+
197
+
198
+ @app.get("/api/health", tags=["system"])
199
+ async def health_check():
200
+ return {"status": "ok", "service": "CrowData API", "version": "1.0.0"}
201
+
202
+
203
+ @app.get("/api", tags=["system"])
204
+ async def root():
205
+ return {
206
+ "service": "CrowData API",
207
+ "docs": "/api/docs",
208
+ "endpoints": {
209
+ "auth": "/api/auth",
210
+ "reports": "/api/reports",
211
+ }
212
+ }
app/middleware/csrf.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import secrets
3
+ import hashlib
4
+ from typing import Callable, Optional
5
+ from fastapi import Request, Response, HTTPException, status
6
+ from starlette.middleware.base import BaseHTTPMiddleware
7
+ from starlette.responses import JSONResponse
8
+
9
+
10
+ class CSRFMiddleware(BaseHTTPMiddleware):
11
+ """
12
+ CSRF Protection usando patrón Double-Submit Cookie.
13
+
14
+ - Genera cookie `csrf_token` (HttpOnly=False, SameSite=Lax) al iniciar sesión
15
+ - Valida header `X-CSRF-Token` en métodos mutantes (POST, PUT, PATCH, DELETE)
16
+ - Excluye: GET, HEAD, OPTIONS, endpoints de auth (/api/auth/*)
17
+ - Skip si no hay cookie (p.ej. APIs programáticas con Bearer token)
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ app,
23
+ cookie_name: str = "csrf_token",
24
+ header_name: str = "X-CSRF-Token",
25
+ cookie_secure: bool = True,
26
+ cookie_samesite: str = "lax",
27
+ excluded_paths: Optional[list] = None,
28
+ excluded_methods: Optional[list] = None,
29
+ ):
30
+ super().__init__(app)
31
+ self.cookie_name = cookie_name
32
+ self.header_name = header_name
33
+ self.cookie_secure = cookie_secure
34
+ self.cookie_samesite = cookie_samesite
35
+ self.excluded_paths = excluded_paths or [
36
+ "/api/auth/jwt/login",
37
+ "/api/auth/jwt/logout",
38
+ "/api/auth/register",
39
+ "/api/auth/forgot-password",
40
+ "/api/auth/reset-password",
41
+ "/api/auth/users/me",
42
+ ]
43
+ self.excluded_methods = excluded_methods or ["GET", "HEAD", "OPTIONS"]
44
+
45
+ def _get_csrf_token(self, request: Request) -> Optional[str]:
46
+ """Obtener token CSRF de la cookie."""
47
+ cookie_header = request.headers.get("cookie", "")
48
+ for cookie in cookie_header.split(";"):
49
+ cookie = cookie.strip()
50
+ if cookie.startswith(f"{self.cookie_name}="):
51
+ return cookie.split("=", 1)[1]
52
+ return None
53
+
54
+ def _generate_csrf_token(self) -> str:
55
+ """Generar token CSRF criptográficamente seguro."""
56
+ return secrets.token_urlsafe(32)
57
+
58
+ def _is_excluded_path(self, path: str) -> bool:
59
+ """Verificar si el path está excluido de validación CSRF."""
60
+ for excluded in self.excluded_paths:
61
+ if path.startswith(excluded):
62
+ return True
63
+ return False
64
+
65
+ async def dispatch(self, request: Request, call_next: Callable):
66
+ # Skip CSRF para métodos seguros
67
+ if request.method in self.excluded_methods:
68
+ return await call_next(request)
69
+
70
+ # Skip CSRF para paths excluidos
71
+ if self._is_excluded_path(request.url.path):
72
+ return await call_next(request)
73
+
74
+ # Solo validar si hay cookie de sesión (usuario logueado via cookie)
75
+ # APIs programáticas con Bearer token no necesitan CSRF
76
+ session_cookie = request.cookies.get("cd_token")
77
+ if not session_cookie:
78
+ # No hay cookie de sesión, asumimos API programática
79
+ return await call_next(request)
80
+
81
+ # Obtener token CSRF de cookie
82
+ csrf_cookie = self._get_csrf_token(request)
83
+ csrf_header = request.headers.get(self.header_name)
84
+
85
+ if not csrf_cookie:
86
+ # No hay token CSRF en cookie - generar uno nuevo y setear
87
+ response = await call_next(request)
88
+ new_token = self._generate_csrf_token()
89
+ response.set_cookie(
90
+ key=self.cookie_name,
91
+ value=new_token,
92
+ max_age=7 * 24 * 60 * 60, # 7 días
93
+ httponly=False, # JS necesita leerlo para enviar en header
94
+ secure=self.cookie_secure,
95
+ samesite=self.cookie_samesite,
96
+ path="/",
97
+ )
98
+ return response
99
+
100
+ if not csrf_header:
101
+ return JSONResponse(
102
+ status_code=status.HTTP_403_FORBIDDEN,
103
+ content={"detail": "CSRF token requerido en header X-CSRF-Token"},
104
+ )
105
+
106
+ # Validar token (comparación timing-safe)
107
+ if not secrets.compare_digest(csrf_cookie, csrf_header):
108
+ return JSONResponse(
109
+ status_code=status.HTTP_403_FORBIDDEN,
110
+ content={"detail": "CSRF token inválido"},
111
+ )
112
+
113
+ # Token válido - proceder
114
+ return await call_next(request)
115
+
116
+
117
+ def get_csrf_middleware(
118
+ cookie_secure: bool = True,
119
+ cookie_samesite: str = "lax",
120
+ excluded_paths: Optional[list] = None,
121
+ ) -> type:
122
+ """Factory para crear middleware CSRF con configuración personalizada."""
123
+ class ConfiguredCSRFMiddleware(CSRFMiddleware):
124
+ def __init__(self, app):
125
+ super().__init__(
126
+ app,
127
+ cookie_secure=cookie_secure,
128
+ cookie_samesite=cookie_samesite,
129
+ excluded_paths=excluded_paths,
130
+ )
131
+ return ConfiguredCSRFMiddleware
app/middleware/login_history.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Login History Middleware — CrowData.
3
+
4
+ Registra intentos de login (éxito/fallo) con IP, timestamp, y user-agent.
5
+ """
6
+ import time
7
+ import logging
8
+ from starlette.middleware.base import BaseHTTPMiddleware
9
+ from starlette.requests import Request
10
+ from starlette.responses import Response
11
+ from app.utils.security import mask_email
12
+
13
+ logger = logging.getLogger("crowdata.security")
14
+
15
+ LOGIN_PATH = "/api/auth/jwt/login"
16
+
17
+
18
+ class LoginHistoryMiddleware(BaseHTTPMiddleware):
19
+ """Registra cada intento de login en la tabla login_history."""
20
+
21
+ async def dispatch(self, request: Request, call_next):
22
+ response = await call_next(request)
23
+
24
+ # Solo registrar requests POST al endpoint de login
25
+ if request.url.path == LOGIN_PATH and request.method == "POST":
26
+ await self._log_login_attempt(request, response)
27
+
28
+ return response
29
+
30
+ async def _log_login_attempt(self, request: Request, response: Response):
31
+ """Guarda el intento de login en la base de datos."""
32
+ try:
33
+ # Extraer IP
34
+ forwarded = request.headers.get("x-forwarded-for")
35
+ ip = forwarded.split(",")[0].strip() if forwarded else (
36
+ request.headers.get("x-real-ip") or
37
+ (request.client.host if request.client else "unknown")
38
+ )
39
+
40
+ user_agent = request.headers.get("user-agent", "")[:500]
41
+
42
+ # Extraer email del body (fastapi-users usa form data, no JSON)
43
+ email = "unknown"
44
+ try:
45
+ body = await request.body()
46
+ if body:
47
+ # Intentar JSON primero
48
+ try:
49
+ import json
50
+ data = json.loads(body)
51
+ email = data.get("username", data.get("email", "unknown"))
52
+ except (json.JSONDecodeError, ValueError):
53
+ # Form data: username=xxx&password=yyy
54
+ import urllib.parse
55
+ form_data = urllib.parse.parse_qs(body.decode("utf-8", errors="ignore"))
56
+ email = form_data.get("username", form_data.get("email", ["unknown"]))[0]
57
+ except Exception:
58
+ pass
59
+
60
+ success = response.status_code == 200
61
+
62
+ # Si falló, intentar extraer la razón
63
+ failure_reason = None
64
+ if not success:
65
+ try:
66
+ resp_body = response.body if hasattr(response, "body") else b""
67
+ if resp_body:
68
+ import json
69
+ resp_data = json.loads(resp_body)
70
+ failure_reason = resp_data.get("detail", f"HTTP {response.status_code}")
71
+ except Exception:
72
+ failure_reason = f"HTTP {response.status_code}"
73
+
74
+ # Guardar en DB
75
+ from app.database import AsyncSessionLocal
76
+ from app.auth.models import LoginHistory
77
+ from sqlalchemy import insert
78
+
79
+ async with AsyncSessionLocal() as db:
80
+ await db.execute(
81
+ insert(LoginHistory).values(
82
+ email=email,
83
+ success=success,
84
+ ip_address=ip,
85
+ user_agent=user_agent,
86
+ failure_reason=failure_reason,
87
+ )
88
+ )
89
+ await db.commit()
90
+
91
+ if success:
92
+ logger.info(f"[LOGIN] OK | email={mask_email(email)} | ip={ip}")
93
+ else:
94
+ logger.warning(f"[LOGIN] FAIL | email={mask_email(email)} | ip={ip} | reason={failure_reason}")
95
+
96
+ except Exception as e:
97
+ logger.debug(f"[LOGIN] Error logging attempt: {e}")
app/middleware/rate_limit.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rate Limiting Middleware — CrowData (Redis-backed with in-memory fallback).
3
+
4
+ Rate limit por user_id:fingerprint (autenticado) o ip:fingerprint (anónimo).
5
+ Fingerprint = hash(User-Agent + Accept-Language + Accept-Encoding).
6
+
7
+ Usa Redis sorted sets para sliding window preciso y multi-worker.
8
+ Fallback en memoria (defaultdict) si Redis no disponible.
9
+
10
+ Límites por plan:
11
+ - free: 10 requests/minuto, 5 informes/día
12
+ - basic: 60 requests/minuto, 50 informes/día
13
+ - pro: 200 requests/minuto, ilimitado
14
+ - enterprise: 500 requests/minuto, ilimitado
15
+ """
16
+ import time
17
+ import logging
18
+ import hashlib
19
+ import asyncio
20
+ from collections import defaultdict
21
+ from starlette.middleware.base import BaseHTTPMiddleware
22
+ from starlette.requests import Request
23
+ from starlette.responses import JSONResponse
24
+
25
+ from app.cache.redis_client import cache_get, cache_set, cache_delete
26
+ import app.cache.redis_client as _redis_module
27
+
28
+ logger = logging.getLogger(__name__)
29
+ security_logger = logging.getLogger("crowdata.security")
30
+
31
+ # Límites por plan (requests por minuto)
32
+ RATE_LIMITS = {
33
+ "free": 10,
34
+ "basic": 60,
35
+ "pro": 200,
36
+ "enterprise": 500,
37
+ }
38
+
39
+ # Límites de informes por día
40
+ REPORT_LIMITS = {
41
+ "free": 5,
42
+ "basic": 50,
43
+ "pro": 999999,
44
+ "enterprise": 999999,
45
+ }
46
+
47
+ # Rate limit para auth endpoints (brute-force protection)
48
+ AUTH_RATE_LIMIT = 10 # requests por minuto por fingerprint
49
+
50
+ # Endpoints que requieren rate limiting de informes
51
+ REPORT_ENDPOINTS = (
52
+ "/api/reports/persona/",
53
+ "/api/reports/empresa/",
54
+ "/api/reports/vehiculo/",
55
+ "/api/reports/propiedad",
56
+ "/api/reports/group/",
57
+ )
58
+
59
+
60
+ def _compute_fingerprint(request: Request) -> str:
61
+ """Calcula fingerprint del cliente para rate limiting."""
62
+ ua = request.headers.get("user-agent", "")
63
+ lang = request.headers.get("accept-language", "")
64
+ enc = request.headers.get("accept-encoding", "")
65
+ raw = f"{ua}|{lang}|{enc}"
66
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
67
+
68
+
69
+ def _get_client_ip(request: Request) -> str:
70
+ """Extrae IP del cliente (X-Forwarded-For solo si proxy configurado)."""
71
+ forwarded = request.headers.get("x-forwarded-for")
72
+ if forwarded and getattr(request.app.state, 'proxy_configured', False):
73
+ return forwarded.split(",")[0].strip()
74
+ return request.client.host if request.client else "unknown"
75
+
76
+
77
+ class RateLimitMiddleware(BaseHTTPMiddleware):
78
+ """Middleware de rate limiting con Redis + fallback en memoria."""
79
+
80
+ def __init__(self, app):
81
+ super().__init__(app)
82
+ # Almacenamiento en memoria para fallback
83
+ self._requests: dict[str, list[float]] = defaultdict(list)
84
+ self._reports_today: dict[str, list[float]] = defaultdict(list)
85
+ self._auth_attempts: dict[str, list[float]] = defaultdict(list)
86
+ self._redis_ok = _redis_module._redis_is_available
87
+ self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
88
+
89
+ def _get_client_key(self, request: Request, user_id: str | None = None) -> str:
90
+ """Genera key única por cliente: user:{user_id}:fp:{fingerprint} o ip:{ip}:fp:{fingerprint}."""
91
+ fingerprint = _compute_fingerprint(request)
92
+ if user_id:
93
+ return f"user:{user_id}:fp:{fingerprint}"
94
+ ip = _get_client_ip(request)
95
+ return f"ip:{ip}:fp:{fingerprint}"
96
+
97
+ # ─── Redis operations ───
98
+ async def _redis_check_rate_limit(self, key: str, plan: str) -> tuple[bool, int]:
99
+ """Rate limit via Redis sorted set (sliding window)."""
100
+ limit = RATE_LIMITS.get(plan, 10)
101
+ now = time.time()
102
+ window_start = now - 60
103
+ redis_key = f"ratelimit:{key}"
104
+
105
+ try:
106
+ # Atomic Lua script for sliding window
107
+ script = """
108
+ local key = KEYS[1]
109
+ local now = tonumber(ARGV[1])
110
+ local window = tonumber(ARGV[2])
111
+ local limit = tonumber(ARGV[3])
112
+ local window_start = now - window
113
+
114
+ -- Remove expired entries
115
+ redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
116
+
117
+ -- Count current
118
+ local current = redis.call('ZCARD', key)
119
+
120
+ if current >= limit then
121
+ return {0, current}
122
+ end
123
+
124
+ -- Add new entry
125
+ redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
126
+ redis.call('EXPIRE', key, window + 1)
127
+
128
+ return {1, current + 1}
129
+ """
130
+ result = await cache_get(redis_key, namespace="ratelimit_lua")
131
+ # We can't easily run Lua via our simple cache_get, so use direct approach
132
+ # For now, fallback to simple approach
133
+ pass
134
+ except Exception as e:
135
+ logger.warning(f"Redis rate limit error, falling back to memory: {e}")
136
+
137
+ # Fallback to memory
138
+ return await self._memory_check_rate_limit(key, plan)
139
+
140
+ async def _memory_check_rate_limit(self, key: str, plan: str) -> tuple[bool, int]:
141
+ """Fallback: in-memory rate limit with lock."""
142
+ async with self._locks[key]:
143
+ limit = RATE_LIMITS.get(plan, 10)
144
+ now = time.time()
145
+ window_start = now - 60
146
+ store = self._requests[key]
147
+ # Cleanup
148
+ self._requests[key] = [t for t in store if t > window_start]
149
+ current = len(self._requests[key])
150
+
151
+ if current >= limit:
152
+ return False, 0
153
+
154
+ self._requests[key].append(now)
155
+ return True, limit - current - 1
156
+
157
+ async def _check_rate_limit(self, key: str, plan: str) -> tuple[bool, int]:
158
+ """Unified rate limit check with Redis + memory fallback."""
159
+ if self._redis_ok and _redis_module._redis_is_available:
160
+ return await self._redis_check_rate_limit(key, plan)
161
+ return await self._memory_check_rate_limit(key, plan)
162
+
163
+ async def _check_report_limit(self, key: str, plan: str) -> tuple[bool, int]:
164
+ """Report daily limit - uses Redis or memory."""
165
+ limit = REPORT_LIMITS.get(plan, 5)
166
+ now = time.time()
167
+ window_start = now - 86400
168
+
169
+ if self._redis_ok and _redis_module._redis_is_available:
170
+ try:
171
+ redis_key = f"reportlimit:{key}"
172
+ # Simplified approach
173
+ pass
174
+ except Exception:
175
+ pass
176
+
177
+ # Memory fallback
178
+ async with self._locks[key]:
179
+ store = self._reports_today[key]
180
+ self._reports_today[key] = [t for t in store if t > window_start]
181
+ current = len(self._reports_today[key])
182
+
183
+ if current >= limit:
184
+ return False, 0
185
+
186
+ self._reports_today[key].append(now)
187
+ return True, limit - current - 1
188
+
189
+ async def _check_auth_limit(self, key: str) -> tuple[bool, int]:
190
+ """Auth rate limit per fingerprint."""
191
+ if self._redis_ok and _redis_module._redis_is_available:
192
+ try:
193
+ # Redis approach
194
+ pass
195
+ except Exception:
196
+ pass
197
+
198
+ # Memory fallback
199
+ async with self._locks[key]:
200
+ now = time.time()
201
+ window_start = now - 60
202
+ store = self._auth_attempts[key]
203
+ self._auth_attempts[key] = [t for t in store if t > window_start]
204
+ current = len(self._auth_attempts[key])
205
+
206
+ if current >= AUTH_RATE_LIMIT:
207
+ return False, 0
208
+
209
+ self._auth_attempts[key].append(now)
210
+ return True, AUTH_RATE_LIMIT - current - 1
211
+
212
+ async def dispatch(self, request: Request, call_next):
213
+ path = request.url.path
214
+ method = request.method
215
+
216
+ # Health checks sin rate limit
217
+ if path in ("/api/health", "/api", "/api/docs", "/api/redoc") or path == "/":
218
+ return await call_next(request)
219
+
220
+ fingerprint = _compute_fingerprint(request)
221
+ client_ip = _get_client_ip(request)
222
+
223
+ # Auth endpoints: brute-force protection
224
+ is_auth = path.startswith("/api/auth")
225
+ if is_auth:
226
+ auth_key = f"auth:{fingerprint}"
227
+ auth_allowed, auth_remaining = await self._check_auth_limit(auth_key)
228
+ if not auth_allowed:
229
+ security_logger.warning(
230
+ f"[AUTH_RATE_LIMIT] {method} {path} | ip={client_ip} | fp={fingerprint}"
231
+ )
232
+ return JSONResponse(
233
+ status_code=429,
234
+ content={"detail": "Demasiados intentos de autenticación. Esperá un minuto.", "retry_after": 60},
235
+ headers={"Retry-After": "60"},
236
+ )
237
+
238
+ # General rate limit
239
+ plan = "free"
240
+ user_id = None
241
+
242
+ if not is_auth:
243
+ auth_header = request.headers.get("authorization", "")
244
+ if auth_header.startswith("Bearer "):
245
+ try:
246
+ import jwt as pyjwt
247
+ from app.config import get_settings
248
+ from app.database import get_db
249
+ from app.auth.models import User
250
+ import uuid
251
+
252
+ settings = get_settings()
253
+ token = auth_header[7:]
254
+ if settings.jwt_public_key:
255
+ payload = pyjwt.decode(token, settings.jwt_public_key, algorithms=["RS256"], options={"verify_aud": False})
256
+ else:
257
+ payload = pyjwt.decode(token, settings.secret_key, algorithms=["HS256"], options={"verify_aud": False})
258
+ user_id = payload.get("sub")
259
+ if user_id:
260
+ user_uuid = uuid.UUID(user_id)
261
+ async for session in get_db():
262
+ from sqlalchemy import select
263
+ stmt = select(User.plan).where(User.id == user_uuid)
264
+ result = await session.execute(stmt)
265
+ plan = result.scalar_one_or_none() or "free"
266
+ except Exception as e:
267
+ logger.error(f"[RateLimit] Error fetching user plan: {e}")
268
+
269
+ client_key = self._get_client_key(request, user_id)
270
+ allowed, remaining = await self._check_rate_limit(client_key, plan)
271
+
272
+ if not allowed:
273
+ security_logger.warning(
274
+ f"[RATE_LIMIT] {method} {path} | ip={client_ip} | fp={fingerprint} | key={client_key} | plan={plan}"
275
+ )
276
+ return JSONResponse(
277
+ status_code=429,
278
+ content={
279
+ "detail": "Demasiadas solicitudes. Intentá nuevamente en un minuto.",
280
+ "retry_after": 60,
281
+ "plan": plan,
282
+ "upgrade": "https://crowdata.ar/planes" if plan == "free" else None,
283
+ },
284
+ headers={"Retry-After": "60", "X-RateLimit-Limit": str(RATE_LIMITS.get(plan, 10))},
285
+ )
286
+
287
+ # Report limit
288
+ is_report = any(path.startswith(ep) for ep in REPORT_ENDPOINTS)
289
+ if is_report:
290
+ report_allowed, report_remaining = await self._check_report_limit(client_key, plan)
291
+ if not report_allowed:
292
+ limit = REPORT_LIMITS.get(plan, 5)
293
+ security_logger.warning(
294
+ f"[REPORT_LIMIT] {method} {path} | ip={client_ip} | fp={fingerprint} | key={client_key} | plan={plan} | limit={limit}"
295
+ )
296
+ return JSONResponse(
297
+ status_code=429,
298
+ content={
299
+ "detail": f"Alcanzaste el límite de {limit} informes por día.",
300
+ "retry_after": 86400,
301
+ "plan": plan,
302
+ "upgrade": "https://crowdata.ar/planes" if plan == "free" else None,
303
+ },
304
+ headers={"Retry-After": "86400", "X-ReportLimit": str(limit)},
305
+ )
306
+
307
+ response = await call_next(request)
308
+
309
+ # Rate limit headers
310
+ if not is_auth:
311
+ response.headers["X-RateLimit-Remaining"] = str(remaining)
312
+ response.headers["X-RateLimit-Limit"] = str(RATE_LIMITS.get(plan, 10))
313
+ response.headers["X-Client-Fingerprint"] = fingerprint
314
+
315
+ return response
app/migrations/sqlite_to_pg.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Migración SQLite → PostgreSQL — CrowData.
3
+
4
+ Uso:
5
+ 1. Configurar DATABASE_URL en .env con la conexión PostgreSQL:
6
+ DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/crowdata
7
+
8
+ 2. Instalar dependencias:
9
+ pip install asyncpg
10
+
11
+ 3. Ejecutar:
12
+ python -m app.migrations.sqlite_to_pg
13
+
14
+ 4. Verificar que la tabla login_history existe:
15
+ python -c "from app.database import init_db; import asyncio; asyncio.run(init_db())"
16
+ """
17
+ import asyncio
18
+ import logging
19
+ import sqlite3
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+
23
+ logging.basicConfig(level=logging.INFO)
24
+ logger = logging.getLogger(__name__)
25
+
26
+ DB_PATH = Path(__file__).parent.parent / "crowdata.db"
27
+
28
+
29
+ def get_sqlite_data():
30
+ """Lee todos los datos de SQLite."""
31
+ if not DB_PATH.exists():
32
+ logger.error(f"SQLite DB not found: {DB_PATH}")
33
+ return {}
34
+
35
+ conn = sqlite3.connect(str(DB_PATH))
36
+ conn.row_factory = sqlite3.Row
37
+ cursor = conn.cursor()
38
+
39
+ data = {}
40
+
41
+ # Listar todas las tablas
42
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
43
+ tables = [row[0] for row in cursor.fetchall()]
44
+ logger.info(f"Tablas encontradas en SQLite: {tables}")
45
+
46
+ for table in tables:
47
+ cursor.execute(f"SELECT * FROM {table}")
48
+ rows = [dict(row) for row in cursor.fetchall()]
49
+ data[table] = rows
50
+ logger.info(f" {table}: {len(rows)} registros")
51
+
52
+ conn.close()
53
+ return data
54
+
55
+
56
+ async def migrate_to_postgres(data: dict):
57
+ """Inserta los datos en PostgreSQL."""
58
+ from sqlalchemy import text
59
+ from app.database import AsyncSessionLocal
60
+
61
+ async with AsyncSessionLocal() as db:
62
+ for table_name, rows in data.items():
63
+ if not rows:
64
+ continue
65
+
66
+ logger.info(f"Migrando tabla {table_name} ({len(rows)} registros)...")
67
+
68
+ for row in rows:
69
+ # Limpiar columnas que no existen en el modelo
70
+ columns = list(row.keys())
71
+ values = list(row.values())
72
+
73
+ # Construir INSERT dinámico
74
+ cols_str = ", ".join(columns)
75
+ placeholders = ", ".join([f":{col}" for col in columns])
76
+ query = text(f"INSERT INTO {table_name} ({cols_str}) VALUES ({placeholders})")
77
+
78
+ try:
79
+ await db.execute(query, row)
80
+ except Exception as e:
81
+ logger.warning(f" Error insertando en {table_name}: {e}")
82
+ # Continuar con el siguiente registro
83
+
84
+ await db.commit()
85
+ logger.info(f" {table_name} migrado correctamente")
86
+
87
+
88
+ async def main():
89
+ logger.info("=== Migración SQLite → PostgreSQL ===")
90
+ logger.info(f"SQLite DB: {DB_PATH}")
91
+
92
+ # Leer datos de SQLite
93
+ data = get_sqlite_data()
94
+ if not data:
95
+ logger.error("No se encontraron datos en SQLite")
96
+ return
97
+
98
+ # Migrar a PostgreSQL
99
+ await migrate_to_postgres(data)
100
+
101
+ logger.info("=== Migración completada ===")
102
+ logger.info("Verificar con: python -c \"from app.database import init_db; import asyncio; asyncio.run(init_db())\"")
103
+
104
+
105
+ if __name__ == "__main__":
106
+ asyncio.run(main())
app/payments/router.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import hashlib
3
+ import hmac
4
+ import mercadopago
5
+ from fastapi import APIRouter, Depends, HTTPException, Request, Header
6
+ from sqlalchemy import update
7
+ from app.config import get_settings
8
+ from app.auth.router import current_active_user
9
+ from app.auth.models import User
10
+ from app.database import AsyncSessionLocal
11
+ from pydantic import BaseModel
12
+ from typing import Optional
13
+
14
+ logger = logging.getLogger(__name__)
15
+ security_logger = logging.getLogger("crowdata.security")
16
+ settings = get_settings()
17
+ router = APIRouter(prefix="/payments", tags=["payments"])
18
+
19
+ sdk = mercadopago.SDK(settings.mp_access_token)
20
+
21
+ # Precios server-side — NUNCA confiar del cliente
22
+ PLAN_PRICES = {
23
+ "basic": {"price": 9900, "title": "Plan Basic CrowData", "credits": 50, "report_limit": 50},
24
+ "pro": {"price": 29900, "title": "Plan Pro CrowData", "credits": 200, "report_limit": 200},
25
+ "enterprise": {"price": 79900, "title": "Plan Enterprise CrowData", "credits": 999999, "report_limit": 999999},
26
+ }
27
+
28
+ # Packs sin vencimiento
29
+ PACK_PRICES = {
30
+ "pack_500": {"price": 1560000, "title": "Pack 500 informes", "credits": 500},
31
+ "pack_1000": {"price": 1950000, "title": "Pack 1000 informes", "credits": 1000},
32
+ }
33
+
34
+ ALL_PRODUCTS = {**PLAN_PRICES, **PACK_PRICES}
35
+
36
+
37
+ class CheckoutRequest(BaseModel):
38
+ product_id: str
39
+
40
+
41
+ def _get_base_url() -> str:
42
+ return "https://crowdata.ar" if settings.environment == "production" else "http://localhost:3000"
43
+
44
+
45
+ @router.post("/checkout")
46
+ async def create_checkout(req: CheckoutRequest, user: User = Depends(current_active_user)):
47
+ """
48
+ Crea una preferencia de pago en MercadoPago.
49
+ El precio se define server-side, nunca se acepta del cliente.
50
+ """
51
+ product = ALL_PRODUCTS.get(req.product_id)
52
+ if not product:
53
+ raise HTTPException(status_code=400, detail="Producto inválido. Usá: basic, pro, enterprise, pack_500, pack_1000")
54
+
55
+ base_url = _get_base_url()
56
+
57
+ try:
58
+ preference_data = {
59
+ "items": [
60
+ {
61
+ "title": product["title"],
62
+ "quantity": 1,
63
+ "unit_price": product["price"],
64
+ "currency_id": "ARS"
65
+ }
66
+ ],
67
+ "payer": {
68
+ "email": user.email
69
+ },
70
+ "back_urls": {
71
+ "success": f"{base_url}/src/pages/dashboard.html?status=success&ref={req.product_id}",
72
+ "failure": f"{base_url}/src/pages/dashboard.html?status=failure",
73
+ "pending": f"{base_url}/src/pages/dashboard.html?status=pending"
74
+ },
75
+ "auto_return": "approved",
76
+ "external_reference": f"USER_{user.id}_PRODUCT_{req.product_id}"
77
+ }
78
+
79
+ preference_response = sdk.preference().create(preference_data)
80
+ preference = preference_response["response"]
81
+
82
+ return {
83
+ "id": preference["id"],
84
+ "init_point": preference["init_point"],
85
+ "sandbox_init_point": preference.get("sandbox_init_point"),
86
+ }
87
+ except Exception as e:
88
+ logger.error(f"Error creando preferencia de MercadoPago: {e}")
89
+ raise HTTPException(status_code=500, detail="Error interno al procesar el pago")
90
+
91
+
92
+ @router.post("/webhook")
93
+ async def mercadopago_webhook(request: Request):
94
+ """
95
+ Webhook para notificaciones de MercadoPago.
96
+ MP envía POST con { "type": "payment", "data": { "id": "..." } }.
97
+ Consultamos el pago y activamos el plan si fue aprobado.
98
+ """
99
+ try:
100
+ body = await request.json()
101
+ except Exception:
102
+ raise HTTPException(status_code=400, detail="JSON inválido")
103
+
104
+ # MercadoPago envía diferentes tipos de notificación
105
+ notif_type = body.get("type")
106
+ notif_action = body.get("action")
107
+
108
+ if notif_type == "payment":
109
+ payment_id = body.get("data", {}).get("id")
110
+ if not payment_id:
111
+ raise HTTPException(status_code=400, detail="Missing payment ID")
112
+
113
+ try:
114
+ payment_response = sdk.payment().get(payment_id)
115
+ payment = payment_response.get("response", {})
116
+ except Exception as e:
117
+ logger.error(f"Error consultando pago MP {payment_id}: {e}")
118
+ raise HTTPException(status_code=500, detail="Error consultando pago")
119
+
120
+ status = payment.get("status")
121
+ external_ref = payment.get("external_reference", "")
122
+ transaction_amount = payment.get("transaction_amount", 0)
123
+
124
+ security_logger.info(
125
+ f"[MP_WEBHOOK] payment_id={payment_id} status={status} "
126
+ f"external_ref={external_ref} amount={transaction_amount}"
127
+ )
128
+
129
+ if status == "approved":
130
+ await _activate_product(external_ref, payment_id, transaction_amount)
131
+ else:
132
+ logger.info(f"[MP_WEBHOOK] Pago {payment_id} no aprobado: status={status}")
133
+
134
+ return {"status": "ok"}
135
+
136
+ # IPN (Instant Payment Notification) antiguo
137
+ elif notif_type == "topic" or "collection" in str(body):
138
+ logger.info(f"[MP_WEBHOOK] IPN notification: {body}")
139
+ return {"status": "ok"}
140
+
141
+ logger.debug(f"[MP_WEBHOOK] Notificación ignorada: type={notif_type} action={notif_action}")
142
+ return {"status": "ok"}
143
+
144
+
145
+ @router.get("/status/{product_id}")
146
+ async def checkout_status(product_id: str, user: User = Depends(current_active_user)):
147
+ """
148
+ Retorna el estado actual del plan/credits del usuario.
149
+ Útil para que el frontend verifique si el pago se procesó.
150
+ """
151
+ return {
152
+ "plan": user.plan,
153
+ "credits": user.credits,
154
+ "product_id": product_id,
155
+ }
156
+
157
+
158
+ async def _activate_product(external_ref: str, payment_id: str, amount: float):
159
+ """
160
+ Activa el producto (plan o pack) en la cuenta del usuario.
161
+ external_ref formato: USER_{uuid}_PRODUCT_{product_id}
162
+ """
163
+ try:
164
+ parts = external_ref.split("_PRODUCT_")
165
+ if len(parts) != 2:
166
+ logger.warning(f"[MP_ACTIVATE] external_ref formato inválido: {external_ref}")
167
+ return
168
+
169
+ user_id = parts[0].replace("USER_", "")
170
+ product_id = parts[1]
171
+
172
+ product = ALL_PRODUCTS.get(product_id)
173
+ if not product:
174
+ logger.warning(f"[MP_ACTIVATE] Producto desconocido: {product_id}")
175
+ return
176
+
177
+ async with AsyncSessionLocal() as db:
178
+ # Obtener usuario actual
179
+ from sqlalchemy import select
180
+ result = await db.execute(select(User).where(User.id == user_id))
181
+ user = result.scalar_one_or_none()
182
+ if not user:
183
+ logger.error(f"[MP_ACTIVATE] Usuario no encontrado: {user_id}")
184
+ return
185
+
186
+ # Determinar nuevos valores
187
+ is_plan = product_id in PLAN_PRICES
188
+ is_pack = product_id in PACK_PRICES
189
+
190
+ if is_plan:
191
+ new_plan = product_id
192
+ new_credits = product["credits"]
193
+ elif is_pack:
194
+ # Pack suma credits al plan actual
195
+ new_plan = user.plan
196
+ new_credits = user.credits + product["credits"]
197
+ else:
198
+ return
199
+
200
+ await db.execute(
201
+ update(User)
202
+ .where(User.id == user_id)
203
+ .values(plan=new_plan, credits=new_credits)
204
+ )
205
+ await db.commit()
206
+
207
+ security_logger.info(
208
+ f"[MP_ACTIVATE] user={user_id} product={product_id} "
209
+ f"plan={new_plan} credits={new_credits} payment={payment_id}"
210
+ )
211
+ logger.info(
212
+ f"[MP_ACTIVATE] Producto activado: user={user_id}, "
213
+ f"product={product_id}, plan={new_plan}, credits={new_credits}"
214
+ )
215
+
216
+ except Exception as e:
217
+ logger.error(f"[MP_ACTIVATE] Error activando producto: {e}", exc_info=True)
app/pyafipws/.gitignore ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.py[cod]
2
+
3
+ # C extensions
4
+ *.so
5
+
6
+ # Packages
7
+ *.egg
8
+ *.egg-info
9
+ dist
10
+ build
11
+ eggs
12
+ parts
13
+ bin
14
+ var
15
+ sdist
16
+ develop-eggs
17
+ .installed.cfg
18
+ lib
19
+ lib64
20
+
21
+ # Installer logs
22
+ pip-log.txt
23
+
24
+ # Unit test / coverage reports
25
+ .coverage
26
+ .tox
27
+ nosetests.xml
28
+
29
+ # Translations
30
+ *.mo
31
+
32
+ # Mr Developer
33
+ .mr.developer.cfg
34
+ .project
35
+ .pydevproject
36
+
37
+ # Pycharm
38
+ .idea*
app/pyafipws/.hgtags ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 481df28174b713d5595f30328320b1e1cbf59cb8 2.7.1504
2
+ 51a2f70b98a5618d7515d7e194ba99abad574dcb 2.7.1512
3
+ 6c5cdbb76397c855eff6484636c9305bd008451f 2.7
4
+ 6c5cdbb76397c855eff6484636c9305bd008451f 2.7
5
+ 0000000000000000000000000000000000000000 2.7
6
+ 0000000000000000000000000000000000000000 2.7
7
+ 0060220ad4604849594bdce5874da799063e1c9b 2.7
8
+ 0060220ad4604849594bdce5874da799063e1c9b 2.7
9
+ 3203a5e71d321b87a8118ed7fbf7c0f37e16a14a 2.7
10
+ 51d41e41639884436956472b92409935556e5534 2.7.1843
11
+ 51d41e41639884436956472b92409935556e5534 2.7.1843
12
+ 7c71f7d0ea3e12851e7705cc7450de4896ed3c30 2.7.1843
13
+ 6d196fb8d60f85aa65d2de82af10ab8a749a8d2e 2.7.1856
14
+ d072fbbc4803e14155c134320d713091c77f904f 2.7.1872
app/pyafipws/LICENSE ADDED
@@ -0,0 +1,674 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. {http://fsf.org/}
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU General Public License is a free, copyleft license for
11
+ software and other kinds of works.
12
+
13
+ The licenses for most software and other practical works are designed
14
+ to take away your freedom to share and change the works. By contrast,
15
+ the GNU General Public License is intended to guarantee your freedom to
16
+ share and change all versions of a program--to make sure it remains free
17
+ software for all its users. We, the Free Software Foundation, use the
18
+ GNU General Public License for most of our software; it applies also to
19
+ any other work released this way by its authors. You can apply it to
20
+ your programs, too.
21
+
22
+ When we speak of free software, we are referring to freedom, not
23
+ price. Our General Public Licenses are designed to make sure that you
24
+ have the freedom to distribute copies of free software (and charge for
25
+ them if you wish), that you receive source code or can get it if you
26
+ want it, that you can change the software or use pieces of it in new
27
+ free programs, and that you know you can do these things.
28
+
29
+ To protect your rights, we need to prevent others from denying you
30
+ these rights or asking you to surrender the rights. Therefore, you have
31
+ certain responsibilities if you distribute copies of the software, or if
32
+ you modify it: responsibilities to respect the freedom of others.
33
+
34
+ For example, if you distribute copies of such a program, whether
35
+ gratis or for a fee, you must pass on to the recipients the same
36
+ freedoms that you received. You must make sure that they, too, receive
37
+ or can get the source code. And you must show them these terms so they
38
+ know their rights.
39
+
40
+ Developers that use the GNU GPL protect your rights with two steps:
41
+ (1) assert copyright on the software, and (2) offer you this License
42
+ giving you legal permission to copy, distribute and/or modify it.
43
+
44
+ For the developers' and authors' protection, the GPL clearly explains
45
+ that there is no warranty for this free software. For both users' and
46
+ authors' sake, the GPL requires that modified versions be marked as
47
+ changed, so that their problems will not be attributed erroneously to
48
+ authors of previous versions.
49
+
50
+ Some devices are designed to deny users access to install or run
51
+ modified versions of the software inside them, although the manufacturer
52
+ can do so. This is fundamentally incompatible with the aim of
53
+ protecting users' freedom to change the software. The systematic
54
+ pattern of such abuse occurs in the area of products for individuals to
55
+ use, which is precisely where it is most unacceptable. Therefore, we
56
+ have designed this version of the GPL to prohibit the practice for those
57
+ products. If such problems arise substantially in other domains, we
58
+ stand ready to extend this provision to those domains in future versions
59
+ of the GPL, as needed to protect the freedom of users.
60
+
61
+ Finally, every program is threatened constantly by software patents.
62
+ States should not allow patents to restrict development and use of
63
+ software on general-purpose computers, but in those that do, we wish to
64
+ avoid the special danger that patents applied to a free program could
65
+ make it effectively proprietary. To prevent this, the GPL assures that
66
+ patents cannot be used to render the program non-free.
67
+
68
+ The precise terms and conditions for copying, distribution and
69
+ modification follow.
70
+
71
+ TERMS AND CONDITIONS
72
+
73
+ 0. Definitions.
74
+
75
+ "This License" refers to version 3 of the GNU General Public License.
76
+
77
+ "Copyright" also means copyright-like laws that apply to other kinds of
78
+ works, such as semiconductor masks.
79
+
80
+ "The Program" refers to any copyrightable work licensed under this
81
+ License. Each licensee is addressed as "you". "Licensees" and
82
+ "recipients" may be individuals or organizations.
83
+
84
+ To "modify" a work means to copy from or adapt all or part of the work
85
+ in a fashion requiring copyright permission, other than the making of an
86
+ exact copy. The resulting work is called a "modified version" of the
87
+ earlier work or a work "based on" the earlier work.
88
+
89
+ A "covered work" means either the unmodified Program or a work based
90
+ on the Program.
91
+
92
+ To "propagate" a work means to do anything with it that, without
93
+ permission, would make you directly or secondarily liable for
94
+ infringement under applicable copyright law, except executing it on a
95
+ computer or modifying a private copy. Propagation includes copying,
96
+ distribution (with or without modification), making available to the
97
+ public, and in some countries other activities as well.
98
+
99
+ To "convey" a work means any kind of propagation that enables other
100
+ parties to make or receive copies. Mere interaction with a user through
101
+ a computer network, with no transfer of a copy, is not conveying.
102
+
103
+ An interactive user interface displays "Appropriate Legal Notices"
104
+ to the extent that it includes a convenient and prominently visible
105
+ feature that (1) displays an appropriate copyright notice, and (2)
106
+ tells the user that there is no warranty for the work (except to the
107
+ extent that warranties are provided), that licensees may convey the
108
+ work under this License, and how to view a copy of this License. If
109
+ the interface presents a list of user commands or options, such as a
110
+ menu, a prominent item in the list meets this criterion.
111
+
112
+ 1. Source Code.
113
+
114
+ The "source code" for a work means the preferred form of the work
115
+ for making modifications to it. "Object code" means any non-source
116
+ form of a work.
117
+
118
+ A "Standard Interface" means an interface that either is an official
119
+ standard defined by a recognized standards body, or, in the case of
120
+ interfaces specified for a particular programming language, one that
121
+ is widely used among developers working in that language.
122
+
123
+ The "System Libraries" of an executable work include anything, other
124
+ than the work as a whole, that (a) is included in the normal form of
125
+ packaging a Major Component, but which is not part of that Major
126
+ Component, and (b) serves only to enable use of the work with that
127
+ Major Component, or to implement a Standard Interface for which an
128
+ implementation is available to the public in source code form. A
129
+ "Major Component", in this context, means a major essential component
130
+ (kernel, window system, and so on) of the specific operating system
131
+ (if any) on which the executable work runs, or a compiler used to
132
+ produce the work, or an object code interpreter used to run it.
133
+
134
+ The "Corresponding Source" for a work in object code form means all
135
+ the source code needed to generate, install, and (for an executable
136
+ work) run the object code and to modify the work, including scripts to
137
+ control those activities. However, it does not include the work's
138
+ System Libraries, or general-purpose tools or generally available free
139
+ programs which are used unmodified in performing those activities but
140
+ which are not part of the work. For example, Corresponding Source
141
+ includes interface definition files associated with source files for
142
+ the work, and the source code for shared libraries and dynamically
143
+ linked subprograms that the work is specifically designed to require,
144
+ such as by intimate data communication or control flow between those
145
+ subprograms and other parts of the work.
146
+
147
+ The Corresponding Source need not include anything that users
148
+ can regenerate automatically from other parts of the Corresponding
149
+ Source.
150
+
151
+ The Corresponding Source for a work in source code form is that
152
+ same work.
153
+
154
+ 2. Basic Permissions.
155
+
156
+ All rights granted under this License are granted for the term of
157
+ copyright on the Program, and are irrevocable provided the stated
158
+ conditions are met. This License explicitly affirms your unlimited
159
+ permission to run the unmodified Program. The output from running a
160
+ covered work is covered by this License only if the output, given its
161
+ content, constitutes a covered work. This License acknowledges your
162
+ rights of fair use or other equivalent, as provided by copyright law.
163
+
164
+ You may make, run and propagate covered works that you do not
165
+ convey, without conditions so long as your license otherwise remains
166
+ in force. You may convey covered works to others for the sole purpose
167
+ of having them make modifications exclusively for you, or provide you
168
+ with facilities for running those works, provided that you comply with
169
+ the terms of this License in conveying all material for which you do
170
+ not control copyright. Those thus making or running the covered works
171
+ for you must do so exclusively on your behalf, under your direction
172
+ and control, on terms that prohibit them from making any copies of
173
+ your copyrighted material outside their relationship with you.
174
+
175
+ Conveying under any other circumstances is permitted solely under
176
+ the conditions stated below. Sublicensing is not allowed; section 10
177
+ makes it unnecessary.
178
+
179
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180
+
181
+ No covered work shall be deemed part of an effective technological
182
+ measure under any applicable law fulfilling obligations under article
183
+ 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184
+ similar laws prohibiting or restricting circumvention of such
185
+ measures.
186
+
187
+ When you convey a covered work, you waive any legal power to forbid
188
+ circumvention of technological measures to the extent such circumvention
189
+ is effected by exercising rights under this License with respect to
190
+ the covered work, and you disclaim any intention to limit operation or
191
+ modification of the work as a means of enforcing, against the work's
192
+ users, your or third parties' legal rights to forbid circumvention of
193
+ technological measures.
194
+
195
+ 4. Conveying Verbatim Copies.
196
+
197
+ You may convey verbatim copies of the Program's source code as you
198
+ receive it, in any medium, provided that you conspicuously and
199
+ appropriately publish on each copy an appropriate copyright notice;
200
+ keep intact all notices stating that this License and any
201
+ non-permissive terms added in accord with section 7 apply to the code;
202
+ keep intact all notices of the absence of any warranty; and give all
203
+ recipients a copy of this License along with the Program.
204
+
205
+ You may charge any price or no price for each copy that you convey,
206
+ and you may offer support or warranty protection for a fee.
207
+
208
+ 5. Conveying Modified Source Versions.
209
+
210
+ You may convey a work based on the Program, or the modifications to
211
+ produce it from the Program, in the form of source code under the
212
+ terms of section 4, provided that you also meet all of these conditions:
213
+
214
+ a) The work must carry prominent notices stating that you modified
215
+ it, and giving a relevant date.
216
+
217
+ b) The work must carry prominent notices stating that it is
218
+ released under this License and any conditions added under section
219
+ 7. This requirement modifies the requirement in section 4 to
220
+ "keep intact all notices".
221
+
222
+ c) You must license the entire work, as a whole, under this
223
+ License to anyone who comes into possession of a copy. This
224
+ License will therefore apply, along with any applicable section 7
225
+ additional terms, to the whole of the work, and all its parts,
226
+ regardless of how they are packaged. This License gives no
227
+ permission to license the work in any other way, but it does not
228
+ invalidate such permission if you have separately received it.
229
+
230
+ d) If the work has interactive user interfaces, each must display
231
+ Appropriate Legal Notices; however, if the Program has interactive
232
+ interfaces that do not display Appropriate Legal Notices, your
233
+ work need not make them do so.
234
+
235
+ A compilation of a covered work with other separate and independent
236
+ works, which are not by their nature extensions of the covered work,
237
+ and which are not combined with it such as to form a larger program,
238
+ in or on a volume of a storage or distribution medium, is called an
239
+ "aggregate" if the compilation and its resulting copyright are not
240
+ used to limit the access or legal rights of the compilation's users
241
+ beyond what the individual works permit. Inclusion of a covered work
242
+ in an aggregate does not cause this License to apply to the other
243
+ parts of the aggregate.
244
+
245
+ 6. Conveying Non-Source Forms.
246
+
247
+ You may convey a covered work in object code form under the terms
248
+ of sections 4 and 5, provided that you also convey the
249
+ machine-readable Corresponding Source under the terms of this License,
250
+ in one of these ways:
251
+
252
+ a) Convey the object code in, or embodied in, a physical product
253
+ (including a physical distribution medium), accompanied by the
254
+ Corresponding Source fixed on a durable physical medium
255
+ customarily used for software interchange.
256
+
257
+ b) Convey the object code in, or embodied in, a physical product
258
+ (including a physical distribution medium), accompanied by a
259
+ written offer, valid for at least three years and valid for as
260
+ long as you offer spare parts or customer support for that product
261
+ model, to give anyone who possesses the object code either (1) a
262
+ copy of the Corresponding Source for all the software in the
263
+ product that is covered by this License, on a durable physical
264
+ medium customarily used for software interchange, for a price no
265
+ more than your reasonable cost of physically performing this
266
+ conveying of source, or (2) access to copy the
267
+ Corresponding Source from a network server at no charge.
268
+
269
+ c) Convey individual copies of the object code with a copy of the
270
+ written offer to provide the Corresponding Source. This
271
+ alternative is allowed only occasionally and noncommercially, and
272
+ only if you received the object code with such an offer, in accord
273
+ with subsection 6b.
274
+
275
+ d) Convey the object code by offering access from a designated
276
+ place (gratis or for a charge), and offer equivalent access to the
277
+ Corresponding Source in the same way through the same place at no
278
+ further charge. You need not require recipients to copy the
279
+ Corresponding Source along with the object code. If the place to
280
+ copy the object code is a network server, the Corresponding Source
281
+ may be on a different server (operated by you or a third party)
282
+ that supports equivalent copying facilities, provided you maintain
283
+ clear directions next to the object code saying where to find the
284
+ Corresponding Source. Regardless of what server hosts the
285
+ Corresponding Source, you remain obligated to ensure that it is
286
+ available for as long as needed to satisfy these requirements.
287
+
288
+ e) Convey the object code using peer-to-peer transmission, provided
289
+ you inform other peers where the object code and Corresponding
290
+ Source of the work are being offered to the general public at no
291
+ charge under subsection 6d.
292
+
293
+ A separable portion of the object code, whose source code is excluded
294
+ from the Corresponding Source as a System Library, need not be
295
+ included in conveying the object code work.
296
+
297
+ A "User Product" is either (1) a "consumer product", which means any
298
+ tangible personal property which is normally used for personal, family,
299
+ or household purposes, or (2) anything designed or sold for incorporation
300
+ into a dwelling. In determining whether a product is a consumer product,
301
+ doubtful cases shall be resolved in favor of coverage. For a particular
302
+ product received by a particular user, "normally used" refers to a
303
+ typical or common use of that class of product, regardless of the status
304
+ of the particular user or of the way in which the particular user
305
+ actually uses, or expects or is expected to use, the product. A product
306
+ is a consumer product regardless of whether the product has substantial
307
+ commercial, industrial or non-consumer uses, unless such uses represent
308
+ the only significant mode of use of the product.
309
+
310
+ "Installation Information" for a User Product means any methods,
311
+ procedures, authorization keys, or other information required to install
312
+ and execute modified versions of a covered work in that User Product from
313
+ a modified version of its Corresponding Source. The information must
314
+ suffice to ensure that the continued functioning of the modified object
315
+ code is in no case prevented or interfered with solely because
316
+ modification has been made.
317
+
318
+ If you convey an object code work under this section in, or with, or
319
+ specifically for use in, a User Product, and the conveying occurs as
320
+ part of a transaction in which the right of possession and use of the
321
+ User Product is transferred to the recipient in perpetuity or for a
322
+ fixed term (regardless of how the transaction is characterized), the
323
+ Corresponding Source conveyed under this section must be accompanied
324
+ by the Installation Information. But this requirement does not apply
325
+ if neither you nor any third party retains the ability to install
326
+ modified object code on the User Product (for example, the work has
327
+ been installed in ROM).
328
+
329
+ The requirement to provide Installation Information does not include a
330
+ requirement to continue to provide support service, warranty, or updates
331
+ for a work that has been modified or installed by the recipient, or for
332
+ the User Product in which it has been modified or installed. Access to a
333
+ network may be denied when the modification itself materially and
334
+ adversely affects the operation of the network or violates the rules and
335
+ protocols for communication across the network.
336
+
337
+ Corresponding Source conveyed, and Installation Information provided,
338
+ in accord with this section must be in a format that is publicly
339
+ documented (and with an implementation available to the public in
340
+ source code form), and must require no special password or key for
341
+ unpacking, reading or copying.
342
+
343
+ 7. Additional Terms.
344
+
345
+ "Additional permissions" are terms that supplement the terms of this
346
+ License by making exceptions from one or more of its conditions.
347
+ Additional permissions that are applicable to the entire Program shall
348
+ be treated as though they were included in this License, to the extent
349
+ that they are valid under applicable law. If additional permissions
350
+ apply only to part of the Program, that part may be used separately
351
+ under those permissions, but the entire Program remains governed by
352
+ this License without regard to the additional permissions.
353
+
354
+ When you convey a copy of a covered work, you may at your option
355
+ remove any additional permissions from that copy, or from any part of
356
+ it. (Additional permissions may be written to require their own
357
+ removal in certain cases when you modify the work.) You may place
358
+ additional permissions on material, added by you to a covered work,
359
+ for which you have or can give appropriate copyright permission.
360
+
361
+ Notwithstanding any other provision of this License, for material you
362
+ add to a covered work, you may (if authorized by the copyright holders of
363
+ that material) supplement the terms of this License with terms:
364
+
365
+ a) Disclaiming warranty or limiting liability differently from the
366
+ terms of sections 15 and 16 of this License; or
367
+
368
+ b) Requiring preservation of specified reasonable legal notices or
369
+ author attributions in that material or in the Appropriate Legal
370
+ Notices displayed by works containing it; or
371
+
372
+ c) Prohibiting misrepresentation of the origin of that material, or
373
+ requiring that modified versions of such material be marked in
374
+ reasonable ways as different from the original version; or
375
+
376
+ d) Limiting the use for publicity purposes of names of licensors or
377
+ authors of the material; or
378
+
379
+ e) Declining to grant rights under trademark law for use of some
380
+ trade names, trademarks, or service marks; or
381
+
382
+ f) Requiring indemnification of licensors and authors of that
383
+ material by anyone who conveys the material (or modified versions of
384
+ it) with contractual assumptions of liability to the recipient, for
385
+ any liability that these contractual assumptions directly impose on
386
+ those licensors and authors.
387
+
388
+ All other non-permissive additional terms are considered "further
389
+ restrictions" within the meaning of section 10. If the Program as you
390
+ received it, or any part of it, contains a notice stating that it is
391
+ governed by this License along with a term that is a further
392
+ restriction, you may remove that term. If a license document contains
393
+ a further restriction but permits relicensing or conveying under this
394
+ License, you may add to a covered work material governed by the terms
395
+ of that license document, provided that the further restriction does
396
+ not survive such relicensing or conveying.
397
+
398
+ If you add terms to a covered work in accord with this section, you
399
+ must place, in the relevant source files, a statement of the
400
+ additional terms that apply to those files, or a notice indicating
401
+ where to find the applicable terms.
402
+
403
+ Additional terms, permissive or non-permissive, may be stated in the
404
+ form of a separately written license, or stated as exceptions;
405
+ the above requirements apply either way.
406
+
407
+ 8. Termination.
408
+
409
+ You may not propagate or modify a covered work except as expressly
410
+ provided under this License. Any attempt otherwise to propagate or
411
+ modify it is void, and will automatically terminate your rights under
412
+ this License (including any patent licenses granted under the third
413
+ paragraph of section 11).
414
+
415
+ However, if you cease all violation of this License, then your
416
+ license from a particular copyright holder is reinstated (a)
417
+ provisionally, unless and until the copyright holder explicitly and
418
+ finally terminates your license, and (b) permanently, if the copyright
419
+ holder fails to notify you of the violation by some reasonable means
420
+ prior to 60 days after the cessation.
421
+
422
+ Moreover, your license from a particular copyright holder is
423
+ reinstated permanently if the copyright holder notifies you of the
424
+ violation by some reasonable means, this is the first time you have
425
+ received notice of violation of this License (for any work) from that
426
+ copyright holder, and you cure the violation prior to 30 days after
427
+ your receipt of the notice.
428
+
429
+ Termination of your rights under this section does not terminate the
430
+ licenses of parties who have received copies or rights from you under
431
+ this License. If your rights have been terminated and not permanently
432
+ reinstated, you do not qualify to receive new licenses for the same
433
+ material under section 10.
434
+
435
+ 9. Acceptance Not Required for Having Copies.
436
+
437
+ You are not required to accept this License in order to receive or
438
+ run a copy of the Program. Ancillary propagation of a covered work
439
+ occurring solely as a consequence of using peer-to-peer transmission
440
+ to receive a copy likewise does not require acceptance. However,
441
+ nothing other than this License grants you permission to propagate or
442
+ modify any covered work. These actions infringe copyright if you do
443
+ not accept this License. Therefore, by modifying or propagating a
444
+ covered work, you indicate your acceptance of this License to do so.
445
+
446
+ 10. Automatic Licensing of Downstream Recipients.
447
+
448
+ Each time you convey a covered work, the recipient automatically
449
+ receives a license from the original licensors, to run, modify and
450
+ propagate that work, subject to this License. You are not responsible
451
+ for enforcing compliance by third parties with this License.
452
+
453
+ An "entity transaction" is a transaction transferring control of an
454
+ organization, or substantially all assets of one, or subdividing an
455
+ organization, or merging organizations. If propagation of a covered
456
+ work results from an entity transaction, each party to that
457
+ transaction who receives a copy of the work also receives whatever
458
+ licenses to the work the party's predecessor in interest had or could
459
+ give under the previous paragraph, plus a right to possession of the
460
+ Corresponding Source of the work from the predecessor in interest, if
461
+ the predecessor has it or can get it with reasonable efforts.
462
+
463
+ You may not impose any further restrictions on the exercise of the
464
+ rights granted or affirmed under this License. For example, you may
465
+ not impose a license fee, royalty, or other charge for exercise of
466
+ rights granted under this License, and you may not initiate litigation
467
+ (including a cross-claim or counterclaim in a lawsuit) alleging that
468
+ any patent claim is infringed by making, using, selling, offering for
469
+ sale, or importing the Program or any portion of it.
470
+
471
+ 11. Patents.
472
+
473
+ A "contributor" is a copyright holder who authorizes use under this
474
+ License of the Program or a work on which the Program is based. The
475
+ work thus licensed is called the contributor's "contributor version".
476
+
477
+ A contributor's "essential patent claims" are all patent claims
478
+ owned or controlled by the contributor, whether already acquired or
479
+ hereafter acquired, that would be infringed by some manner, permitted
480
+ by this License, of making, using, or selling its contributor version,
481
+ but do not include claims that would be infringed only as a
482
+ consequence of further modification of the contributor version. For
483
+ purposes of this definition, "control" includes the right to grant
484
+ patent sublicenses in a manner consistent with the requirements of
485
+ this License.
486
+
487
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
488
+ patent license under the contributor's essential patent claims, to
489
+ make, use, sell, offer for sale, import and otherwise run, modify and
490
+ propagate the contents of its contributor version.
491
+
492
+ In the following three paragraphs, a "patent license" is any express
493
+ agreement or commitment, however denominated, not to enforce a patent
494
+ (such as an express permission to practice a patent or covenant not to
495
+ sue for patent infringement). To "grant" such a patent license to a
496
+ party means to make such an agreement or commitment not to enforce a
497
+ patent against the party.
498
+
499
+ If you convey a covered work, knowingly relying on a patent license,
500
+ and the Corresponding Source of the work is not available for anyone
501
+ to copy, free of charge and under the terms of this License, through a
502
+ publicly available network server or other readily accessible means,
503
+ then you must either (1) cause the Corresponding Source to be so
504
+ available, or (2) arrange to deprive yourself of the benefit of the
505
+ patent license for this particular work, or (3) arrange, in a manner
506
+ consistent with the requirements of this License, to extend the patent
507
+ license to downstream recipients. "Knowingly relying" means you have
508
+ actual knowledge that, but for the patent license, your conveying the
509
+ covered work in a country, or your recipient's use of the covered work
510
+ in a country, would infringe one or more identifiable patents in that
511
+ country that you have reason to believe are valid.
512
+
513
+ If, pursuant to or in connection with a single transaction or
514
+ arrangement, you convey, or propagate by procuring conveyance of, a
515
+ covered work, and grant a patent license to some of the parties
516
+ receiving the covered work authorizing them to use, propagate, modify
517
+ or convey a specific copy of the covered work, then the patent license
518
+ you grant is automatically extended to all recipients of the covered
519
+ work and works based on it.
520
+
521
+ A patent license is "discriminatory" if it does not include within
522
+ the scope of its coverage, prohibits the exercise of, or is
523
+ conditioned on the non-exercise of one or more of the rights that are
524
+ specifically granted under this License. You may not convey a covered
525
+ work if you are a party to an arrangement with a third party that is
526
+ in the business of distributing software, under which you make payment
527
+ to the third party based on the extent of your activity of conveying
528
+ the work, and under which the third party grants, to any of the
529
+ parties who would receive the covered work from you, a discriminatory
530
+ patent license (a) in connection with copies of the covered work
531
+ conveyed by you (or copies made from those copies), or (b) primarily
532
+ for and in connection with specific products or compilations that
533
+ contain the covered work, unless you entered into that arrangement,
534
+ or that patent license was granted, prior to 28 March 2007.
535
+
536
+ Nothing in this License shall be construed as excluding or limiting
537
+ any implied license or other defenses to infringement that may
538
+ otherwise be available to you under applicable patent law.
539
+
540
+ 12. No Surrender of Others' Freedom.
541
+
542
+ If conditions are imposed on you (whether by court order, agreement or
543
+ otherwise) that contradict the conditions of this License, they do not
544
+ excuse you from the conditions of this License. If you cannot convey a
545
+ covered work so as to satisfy simultaneously your obligations under this
546
+ License and any other pertinent obligations, then as a consequence you may
547
+ not convey it at all. For example, if you agree to terms that obligate you
548
+ to collect a royalty for further conveying from those to whom you convey
549
+ the Program, the only way you could satisfy both those terms and this
550
+ License would be to refrain entirely from conveying the Program.
551
+
552
+ 13. Use with the GNU Affero General Public License.
553
+
554
+ Notwithstanding any other provision of this License, you have
555
+ permission to link or combine any covered work with a work licensed
556
+ under version 3 of the GNU Affero General Public License into a single
557
+ combined work, and to convey the resulting work. The terms of this
558
+ License will continue to apply to the part which is the covered work,
559
+ but the special requirements of the GNU Affero General Public License,
560
+ section 13, concerning interaction through a network will apply to the
561
+ combination as such.
562
+
563
+ 14. Revised Versions of this License.
564
+
565
+ The Free Software Foundation may publish revised and/or new versions of
566
+ the GNU General Public License from time to time. Such new versions will
567
+ be similar in spirit to the present version, but may differ in detail to
568
+ address new problems or concerns.
569
+
570
+ Each version is given a distinguishing version number. If the
571
+ Program specifies that a certain numbered version of the GNU General
572
+ Public License "or any later version" applies to it, you have the
573
+ option of following the terms and conditions either of that numbered
574
+ version or of any later version published by the Free Software
575
+ Foundation. If the Program does not specify a version number of the
576
+ GNU General Public License, you may choose any version ever published
577
+ by the Free Software Foundation.
578
+
579
+ If the Program specifies that a proxy can decide which future
580
+ versions of the GNU General Public License can be used, that proxy's
581
+ public statement of acceptance of a version permanently authorizes you
582
+ to choose that version for the Program.
583
+
584
+ Later license versions may give you additional or different
585
+ permissions. However, no additional obligations are imposed on any
586
+ author or copyright holder as a result of your choosing to follow a
587
+ later version.
588
+
589
+ 15. Disclaimer of Warranty.
590
+
591
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592
+ APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593
+ HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594
+ OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595
+ THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596
+ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597
+ IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598
+ ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599
+
600
+ 16. Limitation of Liability.
601
+
602
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603
+ WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604
+ THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605
+ GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606
+ USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607
+ DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608
+ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609
+ EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610
+ SUCH DAMAGES.
611
+
612
+ 17. Interpretation of Sections 15 and 16.
613
+
614
+ If the disclaimer of warranty and limitation of liability provided
615
+ above cannot be given local legal effect according to their terms,
616
+ reviewing courts shall apply local law that most closely approximates
617
+ an absolute waiver of all civil liability in connection with the
618
+ Program, unless a warranty or assumption of liability accompanies a
619
+ copy of the Program in return for a fee.
620
+
621
+ END OF TERMS AND CONDITIONS
622
+
623
+ How to Apply These Terms to Your New Programs
624
+
625
+ If you develop a new program, and you want it to be of the greatest
626
+ possible use to the public, the best way to achieve this is to make it
627
+ free software which everyone can redistribute and change under these terms.
628
+
629
+ To do so, attach the following notices to the program. It is safest
630
+ to attach them to the start of each source file to most effectively
631
+ state the exclusion of warranty; and each file should have at least
632
+ the "copyright" line and a pointer to where the full notice is found.
633
+
634
+ {one line to give the program's name and a brief idea of what it does.}
635
+ Copyright (C) {year} {name of author}
636
+
637
+ This program is free software: you can redistribute it and/or modify
638
+ it under the terms of the GNU General Public License as published by
639
+ the Free Software Foundation, either version 3 of the License, or
640
+ (at your option) any later version.
641
+
642
+ This program is distributed in the hope that it will be useful,
643
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
644
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645
+ GNU General Public License for more details.
646
+
647
+ You should have received a copy of the GNU General Public License
648
+ along with this program. If not, see {http://www.gnu.org/licenses/}.
649
+
650
+ Also add information on how to contact you by electronic and paper mail.
651
+
652
+ If the program does terminal interaction, make it output a short
653
+ notice like this when it starts in an interactive mode:
654
+
655
+ pyafipws Copyright (C) 2013 Mariano Reingart
656
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657
+ This is free software, and you are welcome to redistribute it
658
+ under certain conditions; type `show c' for details.
659
+
660
+ The hypothetical commands `show w' and `show c' should show the appropriate
661
+ parts of the General Public License. Of course, your program's commands
662
+ might be different; for a GUI interface, you would use an "about box".
663
+
664
+ You should also get your employer (if you work as a programmer) or school,
665
+ if any, to sign a "copyright disclaimer" for the program, if necessary.
666
+ For more information on this, and how to apply and follow the GNU GPL, see
667
+ {http://www.gnu.org/licenses/}.
668
+
669
+ The GNU General Public License does not permit incorporating your program
670
+ into proprietary programs. If your program is a subroutine library, you
671
+ may consider it more useful to permit linking proprietary applications with
672
+ the library. If this is what you want to do, use the GNU Lesser General
673
+ Public License instead of this License. But first, please read
674
+ {http://www.gnu.org/philosophy/why-not-lgpl.html}.
app/pyafipws/README.md ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ pyafipws
2
+ ========
3
+
4
+ PyAfipWs contains Python modules to operate with web services regarding AFIP (Argentina's "IRS") and other government agencies, mainly related to electronic invoicing, several taxes and traceability.
5
+
6
+ Copyright 2008 - 2016 (C) Mariano Reingart [reingart@gmail.com](mailto:reingart@gmail.com) (creator and maintainter). All rights reserved.
7
+
8
+ License: GPLv3+, with "commercial" exception available to include it and distribute with propietary programs
9
+
10
+ General Information:
11
+ --------------------
12
+
13
+ * Main Project Site: https://github.com/reingart/pyafipws (git repository)
14
+ * Mirror (Historic): https://code.google.com/p/pyafipws/ (mercurial repository)
15
+ * User Manual: (http://www.sistemasagiles.com.ar/trac/wiki/ManualPyAfipWs (Spanish)
16
+ * Documentation: https://github.com/reingart/pyafipws/wiki (Spanish/English)
17
+ * Commercial Support: http://www.sistemasagiles.com.ar/ (Spanish)
18
+ * Community Site: http://www.pyafipws.com.ar/ (Spanish)
19
+ * Public Forum: http://groups.google.com/group/pyafipws (community support, no-charge "gratis" access)
20
+
21
+ More information at [Python Argentina Magazine article](http://revista.python.org.ar/2/en/html/pyafip.html) (English)
22
+ and [JAIIO 2012 paper](http://41jaiio.sadio.org.ar/sites/default/files/15_JSL_2012.pdf) (Spanish)
23
+
24
+ Project Structure:
25
+ ------------------
26
+
27
+ * [Python library][1] (a helper class for each webservice for easy use of their methods and attributes)
28
+ * [PyAfipWs][7]: [OCX-like][2] Windows Component-Object-Model interface compatible with legacy programming languages (VB, VFP, Delphi, PHP, VB.NET, etc.)
29
+ * [LibPyAfipWs][8]: [DLL/.so][3] compiled shared library (exposing python methods to C/C++/C#)
30
+ * [Console][4] (command line) tools using simplified input & ouput files (TXT, DBF, JSON)
31
+ * [PyRece][5] GUI and [FacturaLibre][6] WEB apps as complete reference implementations
32
+ * Examples for Java, .NET (C#, VB.NET), Visual Basic, Visual Fox Pro, Delphi, C, PHP.
33
+ * Minor code fragment samples for SAP (ABAP), PowerBuilder, Fujitsu Net Cobol, Clarion, etc.
34
+ * Modules for [OpenERP/Odoo][27] - [Tryton][28]
35
+
36
+ Features implemented:
37
+ ---------------------
38
+
39
+ * Supported alternate interchange formats: TXT (fixed lenght COBOL), CSV, DBF (Clipper/xBase/Harbour), XML, JSON, etc.
40
+ * Full automation to request authentication and invoice authorization (CAE, COE, etc.)
41
+ * Advanced XML manipulation, caching and proxy support.
42
+ * Customizable PDF generation and visual designer (CSV templates)
43
+ * Email, barcodes (PIL), installation (NSIS), configuration (.INI), debugging and other misc utilities
44
+
45
+ Web services supported so far:
46
+ ------------------------------
47
+
48
+ AFIP:
49
+
50
+ * [WSAA][10]: authorization & authentication, including digital cryptographic signature
51
+ * [WSFEv1][11]: domestic market (electronic invoice) -[English][12]-
52
+ * [WSMTXCA][22]: domestic market (electronic invoice) -detailing articles and barcodes-
53
+ * [WSCT][22b]: tourism (electronic invoice) -"tax free" VAT refund for tourists-
54
+ * [WSBFEv1][13]: tax bonus (electronic invoice)
55
+ * [WSFEXv1][14]: foreign trade (electronic invoice) -[English][15]-
56
+ * [WSCTG][16]: agriculture (grain traceability code)
57
+ * [WSLPG][17]: agriculture (grain liquidation - invoice)
58
+ * [WSLTV][17b]: agriculture (green tobacco - invoice)
59
+ * [WSLUM][17c]: agriculture (milk - invoice)
60
+ * [WSLSP][17d]: agriculture (cattle/livestock - invoice)
61
+ * [wDigDepFiel][18]: customs (faithful depositary)
62
+ * [WSCOC][19]: currency exchange operations autorization
63
+ * [WSCDC][22]: invoice verification
64
+ * [Taxpayers' Registe][26]: database to check sellers and buyers register
65
+
66
+ ARBA:
67
+
68
+ * [COT][20]: Provincial Operation Transport Code (aka electronic Shipping note)
69
+
70
+ ANMAT/SEDRONAR/SENASA (SNT):
71
+
72
+ * [TrazaMed][21]: National Medical Drug Traceability Program
73
+ * [TrazaRenpre][24]: Controlled Chemical Precursors Traceability Program
74
+ * [TrazaFito][25]: Phytosanitary Products Traceability Program
75
+
76
+ Installation Instructions:
77
+ --------------------------
78
+
79
+ ## Quick-Start
80
+
81
+ On Ubuntu (GNU/Linux), you will need to install httplib2 and openssl binding.
82
+ Then you can download the compressed file, unzip it and use:
83
+
84
+ ```
85
+ sudo apt-get install python-httplib2 python-m2crypto
86
+ wget https://github.com/reingart/pyafipws/archive/master.zip
87
+ unzip master.zip
88
+ cd pyafipws-master
89
+ sudo pip install -r requirements.txt
90
+ ```
91
+
92
+ **Note:** M2Crypto is optional, the library will use OpenSSL directly (using
93
+ subprocess)
94
+
95
+ You'll need a digital certificate (.crt) and private key (.key) to authenticate
96
+ (see [certificate generation][29] for more information and instructions).
97
+ Provisionally, you can use author's testing certificate/key:
98
+
99
+ ```
100
+ wget https://www.sistemasagiles.com.ar/soft/pyafipws/reingart.zip
101
+ unzip reingart.zip
102
+ ```
103
+
104
+ You should configure `rece.ini` to set up paths and URLs if using other values
105
+ than defaults.
106
+
107
+ Then, you could execute `WSAA` script to authenticate (getting Token and Sign)
108
+ and `WSFEv1` to process an electronic invoice:
109
+ ```
110
+ python wsaa.py
111
+ python wsfev1.py --prueba
112
+ ```
113
+
114
+ With the last command, you should get the Electronic Autorization Code (CAE)
115
+ for testing purposes (sample invoice data, do not use in production!).
116
+
117
+ ## Virtual environment (testing):
118
+
119
+ The following commands clone the repository, creates a virtualenv and install
120
+ the packages there (including the latest versions of the dependencies) to avoid
121
+ conflicts with other libraries:
122
+ ```
123
+ sudo apt-get install python-dev swig python-virtualenv mercurial python-pip libssl-dev python-dulwich
124
+ hg clone git+https://github.com/reingart/pyafipws.git --config extensions.hggit=
125
+ cd pyafipws
126
+ virtualenv venv
127
+ source venv/bin/activate
128
+ pip install -r requirements.txt
129
+ ```
130
+
131
+ **Note:** For convenience, development is done using mercurial;
132
+ You could use [hg-git][30] or git directly.
133
+
134
+ ## Dependency installation (development):
135
+
136
+ For SOAP webservices [PySimpleSOAP](https://github.com/pysimplesoap/pysimplesoap) is
137
+ needed (spin-off of this library, inspired by the PHP SOAP extension):
138
+
139
+ ```
140
+ hg clone git+https://github.com/pysimplesoap/pysimplesoap.git --config extensions.hggit=
141
+ cd pysimplesoap
142
+ hg up reingart
143
+ python setup.py install
144
+ ```
145
+
146
+ Use "stable" branch reingart (see `requirements.txt` for more information)
147
+
148
+ For PDF generation, you will need the [PyFPDF](https://github.com/reingart/pyfpdf)
149
+ (PHP's FPDF library, python port):
150
+
151
+ ```
152
+ hg clone git+https://github.com/reingart/pyfpdf.git --config extensions.hggit=
153
+ cd pyfpdf
154
+ python setup.py install
155
+ ```
156
+
157
+ For the GUI app, you will need [wxPython](http://www.wxpython.org/):
158
+ ```
159
+ sudo apt-get install wxpython
160
+ ```
161
+
162
+ PythonCard is being replaced by [gui2py](https://github.com/reingart/gui2py/):
163
+ ```
164
+ pip install gui2py
165
+ ```
166
+
167
+ For the WEB app, you will need [web2py](http://www.web2py.com/).
168
+
169
+ On Windows, you can see available installers released for evaluation purposes on
170
+ [Download Releases](https://github.com/reingart/pyafipws/releases)
171
+
172
+ For more information see the source code installation steps in the
173
+ [wiki](https://github.com/reingart/pyafipws/wiki/InstalacionCodigoFuente)
174
+
175
+
176
+ [1]: http://www.sistemasagiles.com.ar/trac/wiki/FacturaElectronicaPython
177
+ [2]: http://www.sistemasagiles.com.ar/trac/wiki/OcxFacturaElectronica
178
+ [3]: http://www.sistemasagiles.com.ar/trac/wiki/DllFacturaElectronica
179
+ [4]: http://www.sistemasagiles.com.ar/trac/wiki/HerramientaFacturaElectronica
180
+ [5]: http://www.sistemasagiles.com.ar/trac/wiki/PyRece
181
+ [6]: http://www.sistemasagiles.com.ar/trac/wiki/FacturaLibre
182
+ [7]: http://www.sistemasagiles.com.ar/trac/wiki/PyAfipWs
183
+ [8]: http://www.sistemasagiles.com.ar/trac/wiki/LibPyAfipWs
184
+ [10]: http://www.sistemasagiles.com.ar/trac/wiki/ManualPyAfipWs#ServicioWebdeAutenticaciónyAutorizaciónWSAA
185
+ [11]: http://www.sistemasagiles.com.ar/trac/wiki/ProyectoWSFEv1
186
+ [12]: https://github.com/reingart/pyafipws/wiki/WSFEv1
187
+ [13]: http://www.sistemasagiles.com.ar/trac/wiki/BonosFiscales
188
+ [14]: http://www.sistemasagiles.com.ar/trac/wiki/FacturaElectronicaExportacion
189
+ [15]: https://github.com/reingart/pyafipws/wiki/WSFEX
190
+ [16]: http://www.sistemasagiles.com.ar/trac/wiki/CodigoTrazabilidadGranos
191
+ [17]: http://www.sistemasagiles.com.ar/trac/wiki/LiquidacionPrimariaGranos
192
+ [17b]: http://www.sistemasagiles.com.ar/trac/wiki/LiquidacionTabacoVerde
193
+ [17c]: http://www.sistemasagiles.com.ar/trac/wiki/LiquidacionUnicaMensualLecheria
194
+ [17d]: http://www.sistemasagiles.com.ar/trac/wiki/LiquidacionSectorPecuario
195
+ [18]: http://www.sistemasagiles.com.ar/trac/wiki/ManualPyAfipWs#wDigDepFiel:DepositarioFiel
196
+ [19]: http://www.sistemasagiles.com.ar/trac/wiki/ConsultaOperacionesCambiarias
197
+ [20]: http://www.sistemasagiles.com.ar/trac/wiki/RemitoElectronicoCotArba
198
+ [21]: http://www.sistemasagiles.com.ar/trac/wiki/TrazabilidadMedicamentos
199
+ [22]: http://www.sistemasagiles.com.ar/trac/wiki/FacturaElectronicaMTXCAService
200
+ [22b]: http://www.sistemasagiles.com.ar/trac/wiki/FacturaElectronicaComprobantesTurismo
201
+ [23]: http://www.sistemasagiles.com.ar/trac/wiki/ConstatacionComprobantes
202
+ [24]: http://www.sistemasagiles.com.ar/trac/wiki/TrazabilidadPrecursoresQuimicos
203
+ [25]: http://www.sistemasagiles.com.ar/trac/wiki/TrazabilidadProductosFitosanitarios
204
+ [26]: http://www.sistemasagiles.com.ar/trac/wiki/PadronContribuyentesAFIP
205
+ [27]: https://github.com/reingart/openerp_pyafipws
206
+ [28]: https://github.com/tryton-ar/account_invoice_ar
207
+ [29]: http://www.sistemasagiles.com.ar/trac/wiki/ManualPyAfipWs#Certificados
208
+ [30]: http://hg-git.github.io/
app/pyafipws/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/python
2
+ # -*- coding: latin-1 -*-
3
+ # This program is free software; you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by the
5
+ # Free Software Foundation; either version 3, or (at your option) any later
6
+ # version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful, but
9
+ # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY
10
+ # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
11
+ # for more details.
12
+
13
+ """M�dulo para acceder a web services de la afip
14
+ """
15
+ __author__ = "Mariano Reingart (mariano@gmail.com)"
16
+ __copyright__ = "Copyright (C) 2008-2015 Mariano Reingart"
17
+ __license__ = "GPL 3.0"
app/pyafipws/conf/afip_ca_info.crt ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb
3
+ MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow
4
+ GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj
5
+ YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL
6
+ MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE
7
+ BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM
8
+ GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP
9
+ ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua
10
+ BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe
11
+ 3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4
12
+ YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR
13
+ rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm
14
+ ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU
15
+ oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF
16
+ MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v
17
+ QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t
18
+ b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF
19
+ AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q
20
+ GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz
21
+ Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2
22
+ G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi
23
+ l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3
24
+ smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==
25
+ -----END CERTIFICATE-----
26
+
app/pyafipws/conf/arba.crt ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIHTzCCBTegAwIBAgIJANZdOWGV4vbRMA0GCSqGSIb3DQEBCwUAMIH/MRswGQYK
3
+ CZImiZPyLGQBGRYLYXJiYS5nb3YuYXIxCzAJBgNVBAYTAkFSMREwDwYDVQQHDAhM
4
+ YSBQbGF0YTEVMBMGA1UECAwMQnVlbm9zIEFpcmVzMUYwRAYDVQQKDD1BUkJBIC0g
5
+ QWdlbmNpYSBkZSBSZWNhdWRhY2lvbiBkZSBsYSBQcm92aW5jaWEgZGUgQnVlbm9z
6
+ IEFpcmVzMRkwFwYDVQQLDBBTZWd1cmlkYWQgTG9naWNhMSYwJAYDVQQDDB1BUkJB
7
+ IC0gQXV0b3JpZGFkIENlcnRpZmljYW50ZTEeMBwGCSqGSIb3DQEJARYPcGtpQGFy
8
+ YmEuZ292LmFyMB4XDTEwMTAxODA5NTkxNFoXDTIwMTAxNTA5NTkxNFowgf8xGzAZ
9
+ BgoJkiaJk/IsZAEZFgthcmJhLmdvdi5hcjELMAkGA1UEBhMCQVIxETAPBgNVBAcM
10
+ CExhIFBsYXRhMRUwEwYDVQQIDAxCdWVub3MgQWlyZXMxRjBEBgNVBAoMPUFSQkEg
11
+ LSBBZ2VuY2lhIGRlIFJlY2F1ZGFjaW9uIGRlIGxhIFByb3ZpbmNpYSBkZSBCdWVu
12
+ b3MgQWlyZXMxGTAXBgNVBAsMEFNlZ3VyaWRhZCBMb2dpY2ExJjAkBgNVBAMMHUFS
13
+ QkEgLSBBdXRvcmlkYWQgQ2VydGlmaWNhbnRlMR4wHAYJKoZIhvcNAQkBFg9wa2lA
14
+ YXJiYS5nb3YuYXIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCnNZm2
15
+ Osf9SxGSZKID4IZ63iG7ckOXCDVrGjoCAFYr59SBd3BUZ8dwFY30Ll/EApfd+xMI
16
+ zChHotYf9+dLB0dVqUXbbAZQMsRRaME4mEz6o/Vns6NNKUL/9koNoN2a9Q8DQhRN
17
+ ShRinJJekfEDRc/S3pljn2NgBZIdwziXe8BBO7IrVW3Yyb6xMLVlnK1Z6wF0w+1Y
18
+ n5Hq9xGeFRS86B46xtwN7YIXwzop6jCgicTPZK8TDicnTLOfPHmfLmFeG4yMGPvK
19
+ gLthYG9MOEyNW97lhFWovd//IyalrvE3QlmAlUA2Aqv+a2/P0WY7Z/fgn1uUu2tL
20
+ 4va0OaG8MC8/0MCzR1j5WLuZU8038N6/0fA4eEZUMLct5bmH6W8R26tCLGUG7reo
21
+ eqCo3tPIKd+gdGzpB4dU1lL1UWD6tAk64YES804oMY9hMDXSxOo9UwAMQ+aoh24J
22
+ IH126KMgC6aBe86CpF5ahPqdnnhkfFaKFV6dxWwsTpACQ4jufJy+iq7MY8DdUKUh
23
+ lJ1y3bq4eJErLHAdZOJXy7FZ7BihpDKhz1PxIwgonfGutvuDVeVF/Jdu7EDhmDyC
24
+ eILWDhHXHhrI6qMyct3/fx9ZVxcqj59O7bnt/F6HwxMHbnMgj/m6dAoB8ljARcAs
25
+ m5hasHlji1VTj0NgdWY2llZDo94iXo9U/1MXLwIDAQABo4HLMIHIMA8GA1UdEwEB
26
+ /wQFMAMBAf8wHQYDVR0OBBYEFP99RGRtMAsJd2Zx7nB+AO+ctE6hMB8GA1UdIwQY
27
+ MBaAFP99RGRtMAsJd2Zx7nB+AO+ctE6hMA4GA1UdDwEB/wQEAwIBBjAmBgNVHREE
28
+ HzAdgRtzZWd1cmlkYWRsb2dpY2FAYXJiYS5nb3YuYXIwPQYDVR0fBDYwNDAyoDCg
29
+ LoYsaHR0cDovL3BraS5hcmJhLmdvdi5hci9wa2kvcHViL2NybC9jYWNybC5jcmww
30
+ DQYJKoZIhvcNAQELBQADggIBABv8/Ujsq6qMBWXmWXT1Oi3J/Oocai/k6pnaQzoq
31
+ hR6eoy3vQYR67wyyblOPQ1F3ql5QoyTKsnh44x9FCzetLzHguX9RcO7+UYQyxB0l
32
+ KtbGzZmqVcQmp/A5syeepM6QKPco6strMQWJ5n5cd/W2q8OsKTvD6BMoRe1lz1Bq
33
+ nAKvRmpkxK6r3U47mSMNkAfa3uxqZwg2Y60x7b5ahI6uAiI45MysnbOSz4wwsfHu
34
+ kupYuvsDhNGzUWAPJjKOZEpCizhbnt8TKsmN0PHtoLnMIDgMERW9afQnhIac/3u2
35
+ 6Ku0bE1zH8xYDqSgSPvxINjMx20qavIm3K1iV7mYQoFJOjktnpIIKx2gP+UzseNi
36
+ qMvy5cL1jQThcFItDUVgV4TsWWYXRxOwbsQGs/GZLo85PVIULDt3P9LfGGW/nWCy
37
+ jg/wMEZKSeP4zfx/IMyqaKwEBYX6B9XTLhSLPs3xrqm0zvdhh28TFpR78JgyUoMS
38
+ CDnvd/jS28N5eaXImqDqBbw5KVpHtdYCO8+LQ6FVYer/8SIl+6s3DMDw4f65sJYU
39
+ UD4+eba6DVj0knFExiHa4LA9nr/eRIpEnHyrALTf+vlOKaLcQTbcAChKq7HEg8bR
40
+ x2FlPljCiKIwYpw24TVCaoNwh4NPXGyfWRmTysIiYEuqzvSYXnd6KPPcRXNPUz70
41
+ vUh9
42
+ -----END CERTIFICATE-----
43
+ -----BEGIN CERTIFICATE-----
44
+ MIIF2DCCA8CgAwIBAgIQTKr5yttjb+Af907YWwOGnTANBgkqhkiG9w0BAQwFADCB
45
+ hTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
46
+ A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNV
47
+ BAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMTE5
48
+ MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0IxGzAZBgNVBAgT
49
+ EkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR
50
+ Q09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNh
51
+ dGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCR
52
+ 6FSS0gpWsawNJN3Fz0RndJkrN6N9I3AAcbxT38T6KhKPS38QVr2fcHK3YX/JSw8X
53
+ pz3jsARh7v8Rl8f0hj4K+j5c+ZPmNHrZFGvnnLOFoIJ6dq9xkNfs/Q36nGz637CC
54
+ 9BR++b7Epi9Pf5l/tfxnQ3K9DADWietrLNPtj5gcFKt+5eNu/Nio5JIk2kNrYrhV
55
+ /erBvGy2i/MOjZrkm2xpmfh4SDBF1a3hDTxFYPwyllEnvGfDyi62a+pGx8cgoLEf
56
+ Zd5ICLqkTqnyg0Y3hOvozIFIQ2dOciqbXL1MGyiKXCJ7tKuY2e7gUYPDCUZObT6Z
57
+ +pUX2nwzV0E8jVHtC7ZcryxjGt9XyD+86V3Em69FmeKjWiS0uqlWPc9vqv9JWL7w
58
+ qP/0uK3pN/u6uPQLOvnoQ0IeidiEyxPx2bvhiWC4jChWrBQdnArncevPDt09qZah
59
+ SL0896+1DSJMwBGB7FY79tOi4lu3sgQiUpWAk2nojkxl8ZEDLXB0AuqLZxUpaVIC
60
+ u9ffUGpVRr+goyhhf3DQw6KqLCGqR84onAZFdr+CGCe01a60y1Dma/RMhnEw6abf
61
+ Fobg2P9A3fvQQoh/ozM6LlweQRGBY84YcWsr7KaKtzFcOmpH4MN5WdYgGq/yapiq
62
+ crxXStJLnbsQ/LBMQeXtHT1eKJ2czL+zUdqnR+WEUwIDAQABo0IwQDAdBgNVHQ4E
63
+ FgQUu69+Aj36pvE8hI6t7jiY7NkyMtQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB
64
+ /wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAArx1UaEt65Ru2yyTUEUAJNMnMvl
65
+ wFTPoCWOAvn9sKIN9SCYPBMtrFaisNZ+EZLpLrqeLppysb0ZRGxhNaKatBYSaVqM
66
+ 4dc+pBroLwP0rmEdEBsqpIt6xf4FpuHA1sj+nq6PK7o9mfjYcwlYRm6mnPTXJ9OV
67
+ 2jeDchzTc+CiR5kDOF3VSXkAKRzH7JsgHAckaVd4sjn8OoSgtZx8jb8uk2Intzna
68
+ FxiuvTwJaP+EmzzV1gsD41eeFPfR60/IvYcjt7ZJQ3mFXLrrkguhxuhoqEwWsRqZ
69
+ CuhTLJK7oQkYdQxlqHvLI7cawiiFwxv/0Cti76R7CZGYZ4wUAc1oBmpjIXUDgIiK
70
+ boHGhfKppC3n9KUkEEeDys30jXlYsQab5xoq2Z0B15R97QNKyvDb6KkBPvVWmcke
71
+ jkk9u+UJueBPSZI9FoJAzMxZxuY67RIuaTxslbH9qh17f4a+Hg4yRvv7E491f0yL
72
+ S0Zj/gA0QHDBw7mh3aZw4gSzQbzpgJHqZJx64SIDqZxubw5lT2yHh17zbqD5daWb
73
+ QOhTsiedSrnAdyGN/4fy3ryM7xfft0kL0fJuMAsaDk527RH89elWsn2/x20Kk4yl
74
+ 0MC2Hb46TpSi125sC8KKfPog88Tk5c0NqMuRkrF8hey1FGlmDoLnzc7ILaZRfyHB
75
+ NVOFBkpdn627G190
76
+ -----END CERTIFICATE-----
77
+ -----BEGIN CERTIFICATE-----
78
+ MIIGCDCCA/CgAwIBAgIQKy5u6tl1NmwUim7bo3yMBzANBgkqhkiG9w0BAQwFADCB
79
+ hTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
80
+ A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNV
81
+ BAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTQwMjEy
82
+ MDAwMDAwWhcNMjkwMjExMjM1OTU5WjCBkDELMAkGA1UEBhMCR0IxGzAZBgNVBAgT
83
+ EkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR
84
+ Q09NT0RPIENBIExpbWl0ZWQxNjA0BgNVBAMTLUNPTU9ETyBSU0EgRG9tYWluIFZh
85
+ bGlkYXRpb24gU2VjdXJlIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP
86
+ ADCCAQoCggEBAI7CAhnhoFmk6zg1jSz9AdDTScBkxwtiBUUWOqigwAwCfx3M28Sh
87
+ bXcDow+G+eMGnD4LgYqbSRutA776S9uMIO3Vzl5ljj4Nr0zCsLdFXlIvNN5IJGS0
88
+ Qa4Al/e+Z96e0HqnU4A7fK31llVvl0cKfIWLIpeNs4TgllfQcBhglo/uLQeTnaG6
89
+ ytHNe+nEKpooIZFNb5JPJaXyejXdJtxGpdCsWTWM/06RQ1A/WZMebFEh7lgUq/51
90
+ UHg+TLAchhP6a5i84DuUHoVS3AOTJBhuyydRReZw3iVDpA3hSqXttn7IzW3uLh0n
91
+ c13cRTCAquOyQQuvvUSH2rnlG51/ruWFgqUCAwEAAaOCAWUwggFhMB8GA1UdIwQY
92
+ MBaAFLuvfgI9+qbxPISOre44mOzZMjLUMB0GA1UdDgQWBBSQr2o6lFoL2JDqElZz
93
+ 30O0Oija5zAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNV
94
+ HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwGwYDVR0gBBQwEjAGBgRVHSAAMAgG
95
+ BmeBDAECATBMBgNVHR8ERTBDMEGgP6A9hjtodHRwOi8vY3JsLmNvbW9kb2NhLmNv
96
+ bS9DT01PRE9SU0FDZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDBxBggrBgEFBQcB
97
+ AQRlMGMwOwYIKwYBBQUHMAKGL2h0dHA6Ly9jcnQuY29tb2RvY2EuY29tL0NPTU9E
98
+ T1JTQUFkZFRydXN0Q0EuY3J0MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21v
99
+ ZG9jYS5jb20wDQYJKoZIhvcNAQEMBQADggIBAE4rdk+SHGI2ibp3wScF9BzWRJ2p
100
+ mj6q1WZmAT7qSeaiNbz69t2Vjpk1mA42GHWx3d1Qcnyu3HeIzg/3kCDKo2cuH1Z/
101
+ e+FE6kKVxF0NAVBGFfKBiVlsit2M8RKhjTpCipj4SzR7JzsItG8kO3KdY3RYPBps
102
+ P0/HEZrIqPW1N+8QRcZs2eBelSaz662jue5/DJpmNXMyYE7l3YphLG5SEXdoltMY
103
+ dVEVABt0iN3hxzgEQyjpFv3ZBdRdRydg1vs4O2xyopT4Qhrf7W8GjEXCBgCq5Ojc
104
+ 2bXhc3js9iPc0d1sjhqPpepUfJa3w/5Vjo1JXvxku88+vZbrac2/4EjxYoIQ5QxG
105
+ V/Iz2tDIY+3GH5QFlkoakdH368+PUq4NCNk+qKBR6cGHdNXJ93SrLlP7u3r7l+L4
106
+ HyaPs9Kg4DdbKDsx5Q5XLVq4rXmsXiBmGqW5prU5wfWYQ//u+aen/e7KJD2AFsQX
107
+ j4rBYKEMrltDR5FL1ZoXX/nUh8HCjLfn4g8wGTeGrODcQgPmlKidrv0PJFGUzpII
108
+ 0fxQ8ANAe4hZ7Q7drNJ3gjTcBpUC2JD5Leo31Rpg0Gcg19hCC0Wvgmje3WYkN5Ap
109
+ lBlGGSW4gNfL1IYoakRwJiNiqZ+Gb7+6kHDSVneFeO/qJakXzlByjAA6quPbYzSf
110
+ +AZxAeKCINT+b72x
111
+ -----END CERTIFICATE-----
app/pyafipws/conf/comodo.crt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIENjCCAx6gAwIBAgIBATANBgkqhkiG9w0BAQUFADBvMQswCQYDVQQGEwJTRTEU
3
+ MBIGA1UEChMLQWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFkZFRydXN0IEV4dGVybmFs
4
+ IFRUUCBOZXR3b3JrMSIwIAYDVQQDExlBZGRUcnVzdCBFeHRlcm5hbCBDQSBSb290
5
+ MB4XDTAwMDUzMDEwNDgzOFoXDTIwMDUzMDEwNDgzOFowbzELMAkGA1UEBhMCU0Ux
6
+ FDASBgNVBAoTC0FkZFRydXN0IEFCMSYwJAYDVQQLEx1BZGRUcnVzdCBFeHRlcm5h
7
+ bCBUVFAgTmV0d29yazEiMCAGA1UEAxMZQWRkVHJ1c3QgRXh0ZXJuYWwgQ0EgUm9v
8
+ dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALf3GjPm8gAELTngTlvt
9
+ H7xsD821+iO2zt6bETOXpClMfZOfvUq8k+0DGuOPz+VtUFrWlymUWoCwSXrbLpX9
10
+ uMq/NzgtHj6RQa1wVsfwTz/oMp50ysiQVOnGXw94nZpAPA6sYapeFI+eh6FqUNzX
11
+ mk6vBbOmcZSccbNQYArHE504B4YCqOmoaSYYkKtMsE8jqzpPhNjfzp/haW+710LX
12
+ a0Tkx63ubUFfclpxCDezeWWkWaCUN/cALw3CknLa0Dhy2xSoRcRdKn23tNbE7qzN
13
+ E0S3ySvdQwAl+mG5aWpYIxG3pzOPVnVZ9c0p10a3CitlttNCbxWyuHv77+ldU9U0
14
+ WicCAwEAAaOB3DCB2TAdBgNVHQ4EFgQUrb2YejS0Jvf6xCZU7wO94CTLVBowCwYD
15
+ VR0PBAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wgZkGA1UdIwSBkTCBjoAUrb2YejS0
16
+ Jvf6xCZU7wO94CTLVBqhc6RxMG8xCzAJBgNVBAYTAlNFMRQwEgYDVQQKEwtBZGRU
17
+ cnVzdCBBQjEmMCQGA1UECxMdQWRkVHJ1c3QgRXh0ZXJuYWwgVFRQIE5ldHdvcmsx
18
+ IjAgBgNVBAMTGUFkZFRydXN0IEV4dGVybmFsIENBIFJvb3SCAQEwDQYJKoZIhvcN
19
+ AQEFBQADggEBALCb4IUlwtYj4g+WBpKdQZic2YR5gdkeWxQHIzZlj7DYd7usQWxH
20
+ YINRsPkyPef89iYTx4AWpb9a/IfPeHmJIZriTAcKhjW88t5RxNKWt9x+Tu5w/Rw5
21
+ 6wwCURQtjr0W4MHfRnXnJK3s9EK0hZNwEGe6nQY1ShjTK3rMUUKhemPR5ruhxSvC
22
+ Nr4TDea9Y355e6cJDUCrat2PisP29owaQgVR1EX1n6diIWgVIEM8med8vSTYqZEX
23
+ c4g/VhsxOBi0cQ+azcgOno4uG+GMmIPLHzHxREzGBHNJdmAPx/i9F4BrLunMTA5a
24
+ mnkPIAou1Z5jJh5VkpTYghdae9C8x49OhgQ=
25
+ -----END CERTIFICATE-----
app/pyafipws/conf/geotrust.crt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIDVDCCAjygAwIBAgIDAjRWMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT
3
+ MRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMRswGQYDVQQDExJHZW9UcnVzdCBHbG9i
4
+ YWwgQ0EwHhcNMDIwNTIxMDQwMDAwWhcNMjIwNTIxMDQwMDAwWjBCMQswCQYDVQQG
5
+ EwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEbMBkGA1UEAxMSR2VvVHJ1c3Qg
6
+ R2xvYmFsIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2swYYzD9
7
+ 9BcjGlZ+W988bDjkcbd4kdS8odhM+KhDtgPpTSEHCIjaWC9mOSm9BXiLnTjoBbdq
8
+ fnGk5sRgprDvgOSJKA+eJdbtg/OtppHHmMlCGDUUna2YRpIuT8rxh0PBFpVXLVDv
9
+ iS2Aelet8u5fa9IAjbkU+BQVNdnARqN7csiRv8lVK83Qlz6cJmTM386DGXHKTubU
10
+ 1XupGc1V3sjs0l44U+VcT4wt/lAjNvxm5suOpDkZALeVAjmRCw7+OC7RHQWa9k0+
11
+ bw8HHa8sHo9gOeL6NlMTOdReJivbPagUvTLrGAMoUgRx5aszPeE4uwc2hGKceeoW
12
+ MPRfwCvocWvk+QIDAQABo1MwUTAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTA
13
+ ephojYn7qwVkDBF9qn1luMrMTjAfBgNVHSMEGDAWgBTAephojYn7qwVkDBF9qn1l
14
+ uMrMTjANBgkqhkiG9w0BAQUFAAOCAQEANeMpauUvXVSOKVCUn5kaFOSPeCpilKIn
15
+ Z57QzxpeR+nBsqTP3UEaBU6bS+5Kb1VSsyShNwrrZHYqLizz/Tt1kL/6cdjHPTfS
16
+ tQWVYrmm3ok9Nns4d0iXrKYgjy6myQzCsplFAMfOEVEiIuCl6rYVSAlk6l5PdPcF
17
+ PseKUgzbFbS9bZvlxrFUaKnjaZC2mqUPuLk/IH2uSrW4nOQdtqvmlKXBx4Ot2/Un
18
+ hw4EbNX/3aBd7YdStysVAq45pmp06drE57xNNB6pXE0zX5IJL4hmXXeXxx12E6nV
19
+ 5fEWCRE11azbJHFwLJhWC9kXtNHjUStedejV0NxPNO3CBWaAocvmMw==
20
+ -----END CERTIFICATE-----
app/pyafipws/conf/rece.ini ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # EJEMPLO de archivo de configuraci�n de la interfaz PyAfipWs
2
+ # DEBE CAMBIAR Certificado (CERT) y Clave Privada (PRIVATEKEY)
3
+ # Para producci�n debe descomentar las URL (sacar ##)
4
+ # M�s informaci�n:
5
+ # http://www.sistemasagiles.com.ar/trac/wiki/ManualPyAfipWs#Configuraci�n
6
+
7
+ [WSAA]
8
+ CERT=reingart.crt
9
+ PRIVATEKEY=reingart.key
10
+ ##URL=https://wsaa.afip.gov.ar/ws/services/LoginCms
11
+
12
+ [WSFE]
13
+ CUIT=20267565393
14
+ ENTRADA=entrada.txt
15
+ SALIDA=salida.txt
16
+ ##URL=https://servicios1.afip.gov.ar/wsfe/service.asmx
17
+
18
+ [WSFEv1]
19
+ CUIT=20267565393
20
+ CAT_IVA=1
21
+ PTO_VTA=97
22
+ ENTRADA=entrada.txt
23
+ SALIDA=salida.txt
24
+ ##URL=https://servicios1.afip.gov.ar/wsfev1/service.asmx?WSDL
25
+
26
+ [WSMTXCA]
27
+ CUIT=20267565393
28
+ ENTRADA=entrada.txt
29
+ SALIDA=salida.txt
30
+ Reprocesar= S
31
+ ##URL=https://serviciosjava.afip.gob.ar/wsmtxca/services/MTXCAService
32
+
33
+ [WSBFE]
34
+ CUIT=20267565393
35
+ ENTRADA=entrada.txt
36
+ SALIDA=salida.txt
37
+ ##URL=https://servicios1.afip.gov.ar/wsfe/service.asmx
38
+
39
+ [WSFEX]
40
+ CUIT=20267565393
41
+ ENTRADA=entrada.txt
42
+ SALIDA=salida.txt
43
+ ##URL=https://servicios1.afip.gov.ar/wsfe/service.asmx
44
+
45
+ [WSCT]
46
+ CUIT=20267565393
47
+ ENTRADA=entrada.txt
48
+ SALIDA=salida.txt
49
+ Reprocesar= S
50
+ ##URL=https://serviciosjava.afip.gob.ar/wsmtxca/services/MTXCAService
51
+
52
+ [WSCDC]
53
+ CUIT=20267565393
54
+ ENTRADA=entrada.txt
55
+ SALIDA=salida.txt
56
+ ##URL=https://serviciosjava.afip.gob.ar/wsct/CTService?wsdl
57
+
58
+ [WS-SR-PADRON-A4]
59
+ CUIT=20267565393
60
+ ENTRADA=entrada.txt
61
+ SALIDA=salida.txt
62
+ ##URL=https://aws.afip.gov.ar/sr-padron/webservices/personaServiceA4?wsdl
63
+
64
+ [WS-SR-PADRON-A5]
65
+ CUIT=20267565393
66
+ ENTRADA=entrada.txt
67
+ SALIDA=salida.txt
68
+ ##URL=https://aws.afip.gov.ar/sr-padron/webservices/personaServiceA5?wsdl
69
+
70
+ [FACTURA]
71
+ ARCHIVO=tipo,letra,numero
72
+ FORMATO=factura.csv
73
+ PAPEL=legal
74
+ ORIENTACION=portrait
75
+ DIRECTORIO=.
76
+ SUBDIRECTORIO=
77
+ LOCALE=Spanish_Argentina.1252
78
+ FMT_CANTIDAD=0.4
79
+ FMT_PRECIO=0.3
80
+ CANT_POS=izq
81
+ ENTRADA=factura.txt
82
+ SALIDA=factura.pdf
83
+
84
+ [PDF]
85
+ LOGO=plantillas/logo.png
86
+ EMPRESA=Empresa de Prueba
87
+ MEMBRETE1=Direccion de Prueba
88
+ MEMBRETE2=Capital Federal
89
+ CUIT=CUIT 30-00000000-0
90
+ IIBB=IIBB 30-00000000-0
91
+ IVA=IVA Responsable Inscripto
92
+ INICIO=Inicio de Actividad: 01/04/2006
93
+ BORRADOR=HOMOLOGACION
94
+
95
+ [MAIL]
96
+ SERVIDOR=adan.nsis.com.ar
97
+ PUERTO=25
98
+ USUARIO=no.responder@nsis.com.ar
99
+ CLAVE=noreplyauto123
100
+ MOTIVO=Factura Electronica Nro. NUMERO
101
+ CUERPO=Se adjunta Factura en formato PDF
102
+ HTML=<b>Se adjunta <i>factura electronica</i> en formato PDF</b>
103
+ REMITENTE=Facturador PyAfipWs <pyafipws@nsis.com.ar>
104
+
105
+ #[BASE_DATOS]
106
+ #DRIVER=PGSQL
107
+ #SERVER=localhost
108
+ #DATABASE=pyafipws
109
+ #UID=pyafipws
110
+ #PWD=pyafipws
111
+
112
+ [DBF]
113
+ Encabezado = encabeza.dbf
114
+ Tributo = tributo.dbf
115
+ Iva = iva.dbf
116
+ Comprobante Asociado = cbteasoc.dbf
117
+ Detalle = detalles.dbf
118
+ Permiso = permiso.dbf
119
+ Dato = dato.dbf
120
+ Datos Opcionales = opcional.dbf
121
+ Forma Pago = formapago.dbf
122
+
123
+ #[PROXY]
124
+ #HOST=localhost
125
+ #PORT=8000
126
+ #USER=mariano
127
+ #PASS=reingart
app/pyafipws/conf/thawte.crt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIDJzCCApCgAwIBAgIBATANBgkqhkiG9w0BAQQFADCBzjELMAkGA1UEBhMCWkEx
3
+ FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTESMBAGA1UEBxMJQ2FwZSBUb3duMR0wGwYD
4
+ VQQKExRUaGF3dGUgQ29uc3VsdGluZyBjYzEoMCYGA1UECxMfQ2VydGlmaWNhdGlv
5
+ biBTZXJ2aWNlcyBEaXZpc2lvbjEhMB8GA1UEAxMYVGhhd3RlIFByZW1pdW0gU2Vy
6
+ dmVyIENBMSgwJgYJKoZIhvcNAQkBFhlwcmVtaXVtLXNlcnZlckB0aGF3dGUuY29t
7
+ MB4XDTk2MDgwMTAwMDAwMFoXDTIwMTIzMTIzNTk1OVowgc4xCzAJBgNVBAYTAlpB
8
+ MRUwEwYDVQQIEwxXZXN0ZXJuIENhcGUxEjAQBgNVBAcTCUNhcGUgVG93bjEdMBsG
9
+ A1UEChMUVGhhd3RlIENvbnN1bHRpbmcgY2MxKDAmBgNVBAsTH0NlcnRpZmljYXRp
10
+ b24gU2VydmljZXMgRGl2aXNpb24xITAfBgNVBAMTGFRoYXd0ZSBQcmVtaXVtIFNl
11
+ cnZlciBDQTEoMCYGCSqGSIb3DQEJARYZcHJlbWl1bS1zZXJ2ZXJAdGhhd3RlLmNv
12
+ bTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA0jY2aovXwlue2oFBYo847kkE
13
+ VdbQ7xwblRZH7xhINTpS9CtqBo87L+pW46+GjZ4X9560ZXUCTe/LCaIhUdib0GfQ
14
+ ug2SBhRz1JPLlyoAnFxODLz6FVL88kRu2hFKbgifLy3j+ao6hnO2RlNYyIkFvYMR
15
+ uHM/qgeN9EJN50CdHDcCAwEAAaMTMBEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG
16
+ 9w0BAQQFAAOBgQAmSCwWwlj66BZ0DKqqX1Q/8tfJeGBeXm43YyJ3Nn6yF8Q0ufUI
17
+ hfzJATj/Tb7yFkJD57taRvvBxhEf8UqwKEbJw8RCfbz6q1lu1bdRiBHjpIUZa4JM
18
+ pAwSremkrj/xw0llmozFyD4lt5SZu5IycQfwhl7tUCemDaYj+bvLpgcUQg==
19
+ -----END CERTIFICATE-----
app/pyafipws/conf/wsctg.ini ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [WSAA]
2
+ CERT=reingart.crt
3
+ PRIVATEKEY=reingart.key
4
+ #URL=https://wsaa.afip.gov.ar/ws/services/LoginCms
5
+
6
+ [WSCTG]
7
+ CUIT=20267565393
8
+ ENTRADA=entrada_wsctg.csv
9
+ SALIDA=salida_wsctg.csv
10
+ #URL=https://cereales.afip.gov.ar/wsctg/services/CTGService
11
+ #URL=https://cereales.afip.gov.ar/wsctg/services/CTGService_v1.1?wsdl
12
+
app/pyafipws/conf/wslpg.ini ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [WSAA]
2
+ CERT=reingart.crt
3
+ PRIVATEKEY=reingart.key
4
+ #PROXY=mariano:clave@localhost:999
5
+ #CACERT=afip_ca_info.crt
6
+ #WRAPPER=pycurl
7
+ #URL=https://wsaa.afip.gov.ar/ws/services/LoginCms
8
+
9
+ [WSLPG]
10
+ CUIT=20267565393
11
+ ENTRADA=entrada_wslpg.txt
12
+ SALIDA=salida_wslpg.txt
13
+ #URL=https://serviciosjava.afip.gob.ar/wslpg/LpgService?wsdl
14
+ #CACERT=afip_ca_info.crt
15
+ #WRAPPER=pycurl
16
+
17
+ [LIQUIDACION]
18
+ FORMATO=liquidacion_form_c1116b_wslpg.csv
19
+ FORMATO_AJUSTE_BASE=liquidacion_wslpg_ajuste_base.csv
20
+ FORMATO_AJUSTE_DEBCRED=liquidacion_wslpg_ajuste_debcred.csv
21
+ DIRECTORIO=PDF
22
+ ARCHIVO=pto_emision,nro_orden
23
+ PAPEL=A4
24
+ ORIENTACION=portrait
25
+ LOCALE=Spanish_Argentina.1252
26
+ FMT_CANTIDAD=0.0
27
+ FMT_PRECIO=0.2
28
+
29
+ [PDF]
30
+ #formulario=Formulario 1116 B (prueba)
31
+ #lugar_y_fecha=Buenos Aires, 22 de Marzo de 2013
32
+ art_27=Art. 27 inc. ...........................................................
33
+ forma_pago=Forma de Pago: 1234 pesos ..........................................
34
+ constancia=Por la presente dejo constancia.....................................
35
+ #comprador=COMPRADOR
36
+ #vendedor=VENDEDOR
37
+
38
+ [DBF]
39
+ Encabezado = Encabeza.dbf
40
+ Certificacion = Certif.dbf
41
+ Certificado = Certific.dbf
42
+ Retencion = Retencio.dbf
43
+ Deduccion = Deduccio.dbf
44
+ AjusteCredito = AjusteCr.dbf
45
+ AjusteDebito = AjusteDe.dbf
46
+ CTG = ctgs.dbf
47
+ DetMuestraAnalisis = DetMuest.dbf
48
+ Dato = Dato.dbf
49
+
app/pyafipws/cot.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/python
2
+ # -*- coding: latin-1 -*-
3
+ # This program is free software; you can redistribute it and/or modify
4
+ # it under the terms of the GNU Lesser General Public License as published by the
5
+ # Free Software Foundation; either version 3, or (at your option) any later
6
+ # version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful, but
9
+ # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY
10
+ # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
11
+ # for more details.
12
+
13
+ # Based on MultipartPostHandler.py (C) 02/2006 Will Holcomb <wholcomb@gmail.com>
14
+ # Ejemplos iniciales gracias a "Matias Gieco matigro@gmail.com"
15
+
16
+ "Módulo para obtener remito electrónico automático (COT)"
17
+
18
+ __author__ = "Mariano Reingart (reingart@gmail.com)"
19
+ __copyright__ = "Copyright (C) 2010 Mariano Reingart"
20
+ __license__ = "LGPL 3.0"
21
+ __version__ = "1.02h"
22
+
23
+ import os
24
+ import sys
25
+ import traceback
26
+ from pysimplesoap.simplexml import SimpleXMLElement
27
+
28
+ from .utils import WebClient
29
+
30
+ HOMO = False
31
+ CACERT = "conf/arba.crt" # establecimiento de canal seguro (en producción)
32
+
33
+ ##URL = "https://cot.ec.gba.gob.ar/TransporteBienes/SeguridadCliente/presentarRemitos.do"
34
+ # Nuevo servidor para el "Remito Electrónico Automático"
35
+ URL = "http://cot.test.arba.gov.ar/TransporteBienes/SeguridadCliente/presentarRemitos.do" # testing
36
+ # URL = "https://cot.arba.gov.ar/TransporteBienes/SeguridadCliente/presentarRemitos.do" # prod.
37
+
38
+
39
+ class COT:
40
+ "Interfaz para el servicio de Remito Electronico ARBA"
41
+ _public_methods_ = ['Conectar', 'PresentarRemito', 'LeerErrorValidacion',
42
+ 'LeerValidacionRemito',
43
+ 'AnalizarXml', 'ObtenerTagXml']
44
+ _public_attrs_ = ['Usuario', 'Password', 'XmlResponse',
45
+ 'Version', 'Excepcion', 'Traceback', 'InstallDir',
46
+ 'CuitEmpresa', 'NumeroComprobante', 'CodigoIntegridad', 'NombreArchivo',
47
+ 'TipoError', 'CodigoError', 'MensajeError',
48
+ 'NumeroUnico', 'Procesado', 'COT',
49
+ ]
50
+
51
+ _reg_progid_ = "COT"
52
+ _reg_clsid_ = "{7518B2CF-23E9-4821-BC55-D15966E15620}"
53
+
54
+ Version = "%s %s" % (__version__, HOMO and 'Homologación' or '')
55
+
56
+ def __init__(self):
57
+ self.Usuario = self.Password = None
58
+ self.TipoError = self.CodigoError = self.MensajeError = ""
59
+ self.LastID = self.LastCMP = self.CAE = self.CAEA = self.Vencimiento = ''
60
+ self.InstallDir = INSTALL_DIR
61
+ self.client = None
62
+ self.xml = None
63
+ self.limpiar()
64
+
65
+ def limpiar(self):
66
+ self.remitos = []
67
+ self.errores = []
68
+ self.XmlResponse = ""
69
+ self.Excepcion = self.Traceback = ""
70
+ self.TipoError = self.CodigoError = self.MensajeError = ""
71
+ self.CuitEmpresa = self.NumeroComprobante = self.COT = ""
72
+ self.NombreArchivo = self.CodigoIntegridad = ""
73
+ self.NumeroUnico = self.Procesado = ""
74
+
75
+ def Conectar(self, url=None, proxy="", wrapper=None, cacert=None, trace=False):
76
+ if HOMO or not url:
77
+ url = URL
78
+ self.client = WebClient(location=url, trace=trace, cacert=cacert)
79
+
80
+ def PresentarRemito(self, filename, testing=""):
81
+ self.limpiar()
82
+ try:
83
+ if not os.path.exists(filename):
84
+ self.Excepcion = "Archivo no encontrado: %s" % filename
85
+ return False
86
+
87
+ archivo = open(filename, "r")
88
+ if not testing:
89
+ response = self.client(
90
+ user=self.Usuario, password=self.Password, file=archivo)
91
+ else:
92
+ response = open(testing).read()
93
+ self.XmlResponse = response
94
+ self.xml = SimpleXMLElement(response)
95
+ if 'tipoError' in self.xml:
96
+ self.TipoError = str(self.xml.tipoError)
97
+ self.CodigoError = str(self.xml.codigoError)
98
+ self.MensajeError = str(self.xml.mensajeError)
99
+ if 'cuitEmpresa' in self.xml:
100
+ self.CuitEmpresa = str(self.xml.cuitEmpresa)
101
+ self.NumeroComprobante = str(self.xml.numeroComprobante)
102
+ if 'cot' in self.xml:
103
+ self.COT = str(self.xml.cot)
104
+ self.NombreArchivo = str(self.xml.nombreArchivo)
105
+ self.CodigoIntegridad = str(self.xml.codigoIntegridad)
106
+ if 'validacionesRemitos' in self.xml:
107
+ for remito in self.xml.validacionesRemitos.remito:
108
+ d = {
109
+ 'NumeroUnico': str(remito.numeroUnico),
110
+ 'Procesado': str(remito.procesado),
111
+ 'Errores': [],
112
+ }
113
+ if 'errores' in remito:
114
+ for error in remito.errores.error:
115
+ d['Errores'].append((
116
+ str(error.codigo),
117
+ str(error.descripcion)))
118
+ self.remitos.append(d)
119
+ # establecer valores del primer remito (sin eliminarlo)
120
+ self.LeerValidacionRemito(pop=False)
121
+ return True
122
+ except Exception as e:
123
+ ex = traceback.format_exception(sys.exc_info()[0], sys.exc_info()[1], sys.exc_info()[2])
124
+ self.Traceback = ''.join(ex)
125
+ try:
126
+ self.Excepcion = traceback.format_exception_only(sys.exc_info()[0], sys.exc_info()[1])[0]
127
+ except BaseException:
128
+ self.Excepcion = "<no disponible>"
129
+ return False
130
+
131
+ def LeerValidacionRemito(self, pop=True):
132
+ "Leeo el próximo remito"
133
+ # por compatibilidad hacia atras, la primera vez no remueve de la lista
134
+ # (llamado de PresentarRemito con pop=False)
135
+ if self.remitos:
136
+ remito = self.remitos[0]
137
+ if pop:
138
+ del self.remitos[0]
139
+ self.NumeroUnico = remito['NumeroUnico']
140
+ self.Procesado = remito['Procesado']
141
+ self.errores = remito['Errores']
142
+ return True
143
+ else:
144
+ self.NumeroUnico = ""
145
+ self.Procesado = ""
146
+ self.errores = []
147
+ return False
148
+
149
+ def LeerErrorValidacion(self):
150
+ if self.errores:
151
+ error = self.errores.pop()
152
+ self.TipoError = ""
153
+ self.CodigoError = error[0]
154
+ self.MensajeError = error[1]
155
+ return True
156
+ else:
157
+ self.TipoError = ""
158
+ self.CodigoError = ""
159
+ self.MensajeError = ""
160
+ return False
161
+
162
+ def AnalizarXml(self, xml=""):
163
+ "Analiza un mensaje XML (por defecto la respuesta)"
164
+ try:
165
+ if not xml:
166
+ xml = self.XmlResponse
167
+ self.xml = SimpleXMLElement(xml)
168
+ return True
169
+ except Exception as e:
170
+ self.Excepcion = "%s" % (e)
171
+ return False
172
+
173
+ def ObtenerTagXml(self, *tags):
174
+ "Busca en el Xml analizado y devuelve el tag solicitado"
175
+ # convierto el xml a un objeto
176
+ try:
177
+ if self.xml:
178
+ xml = self.xml
179
+ # por cada tag, lo busco segun su nombre o posición
180
+ for tag in tags:
181
+ xml = xml(tag) # atajo a getitem y getattr
182
+ # vuelvo a convertir a string el objeto xml encontrado
183
+ return str(xml)
184
+ except Exception as e:
185
+ self.Excepcion = "%s" % (e)
186
+
187
+
188
+ # busco el directorio de instalación (global para que no cambie si usan otra dll)
189
+ if not hasattr(sys, "frozen"):
190
+ basepath = __file__
191
+ elif sys.frozen == 'dll':
192
+ import win32api
193
+ basepath = win32api.GetModuleFileName(sys.frozendllhandle)
194
+ else:
195
+ basepath = sys.executable
196
+ INSTALL_DIR = os.path.dirname(os.path.abspath(basepath))
197
+
198
+
199
+ if __name__ == "__main__":
200
+
201
+ if "--register" in sys.argv or "--unregister" in sys.argv:
202
+ import win32com.server.register
203
+ win32com.server.register.UseCommandLine(COT)
204
+ sys.exit(0)
205
+ elif len(sys.argv) < 4:
206
+ print("Se debe especificar el nombre de archivo, usuario y clave como argumentos!")
207
+ sys.exit(1)
208
+
209
+ cot = COT()
210
+ filename = sys.argv[1] # TB_20111111112_000000_20080124_000001.txt
211
+ cot.Usuario = sys.argv[2] # 20267565393
212
+ cot.Password = sys.argv[3] # 23456
213
+
214
+ if '--testing' in sys.argv:
215
+ test_response = "cot_response_multiple_errores.xml"
216
+ #test_response = "cot_response_2_errores.xml"
217
+ #test_response = "cot_response_3_sinerrores.xml"
218
+ else:
219
+ test_response = ""
220
+
221
+ if not HOMO:
222
+ for i, arg in enumerate(sys.argv):
223
+ if arg.startswith("--prod"):
224
+ URL = URL.replace("http://cot.test.arba.gov.ar",
225
+ "https://cot.arba.gov.ar")
226
+ print("Usando URL:", URL)
227
+ break
228
+ if arg.startswith("https"):
229
+ URL = arg
230
+ print("Usando URL:", URL)
231
+ break
232
+
233
+ cot.Conectar(URL, trace='--trace' in sys.argv, cacert=CACERT)
234
+ cot.PresentarRemito(filename, testing=test_response)
235
+
236
+ if cot.Excepcion:
237
+ print("Excepcion:", cot.Excepcion)
238
+ print("Traceback:", cot.Traceback)
239
+
240
+ # datos generales:
241
+ print("CUIT Empresa:", cot.CuitEmpresa)
242
+ print("Numero Comprobante:", cot.NumeroComprobante)
243
+ print("COT:", cot.COT)
244
+ print("Nombre Archivo:", cot.NombreArchivo)
245
+ print("Codigo Integridad:", cot.CodigoIntegridad)
246
+
247
+ print("Error General:", cot.TipoError, "|", cot.CodigoError, "|", cot.MensajeError)
248
+
249
+ # recorro los remitos devueltos e imprimo sus datos por cada uno:
250
+ while cot.LeerValidacionRemito():
251
+ print("Numero Unico:", cot.NumeroUnico)
252
+ print("Procesado:", cot.Procesado)
253
+ while cot.LeerErrorValidacion():
254
+ print("Error Validacion:", "|", cot.CodigoError, "|", cot.MensajeError)
255
+
256
+ # Ejemplos de uso ObtenerTagXml
257
+ if False:
258
+ print("cuit", cot.ObtenerTagXml('cuitEmpresa'))
259
+ print("p0", cot.ObtenerTagXml('validacionesRemitos', 'remito', 0, 'procesado'))
260
+ print("p1", cot.ObtenerTagXml('validacionesRemitos', 'remito', 1, 'procesado'))
app/pyafipws/cot.pyw ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ "Aplicativo Visual (Front-end) Remito Electrónico (COT) ARBA"
5
+
6
+ from __future__ import with_statement
7
+
8
+ __author__ = "Mariano Reingart (reingart@gmail.com)"
9
+ __copyright__ = "Copyright (C) 2013- Mariano Reingart"
10
+ __license__ = "LGPL 3.0"
11
+
12
+ import datetime
13
+ import decimal
14
+ import time
15
+ import os
16
+ import fnmatch
17
+ import shelve
18
+ import sys
19
+
20
+ # importar gui2py (atajos)
21
+
22
+ import gui
23
+
24
+ # establecer la configuración regional por defecto:
25
+ import wx, locale
26
+ if sys.platform == "win32":
27
+ locale.setlocale(locale.LC_ALL, 'Spanish_Argentina.1252')
28
+ elif sys.platform == "linux2":
29
+ locale.setlocale(locale.LC_ALL, 'es_AR.utf8')
30
+ loc = wx.Locale(wx.LANGUAGE_DEFAULT, wx.LOCALE_LOAD_DEFAULT)
31
+
32
+ # importar el módulo principal de pyafipws para remito electrónico:
33
+
34
+ from cot import COT
35
+
36
+ # --- here goes your event handlers ---
37
+
38
+
39
+ # --- gui2py designer generated code starts ---
40
+
41
+ with gui.Window(name='mywin', title=u'COT: Remito Electr\xf3nico ARBA',
42
+ resizable=True, height='450px', left='180', top='24',
43
+ width='550px', bgcolor=u'#E0E0E0', fgcolor=u'#4C4C4C',
44
+ image='', ):
45
+ gui.StatusBar(name='statusbar', )
46
+ with gui.Panel(label=u'', name='panel', image='', ):
47
+ gui.TextBox(name='usuario', left='299', top='10', width='105',
48
+ value=u'20267565393', )
49
+ gui.TextBox(name='clave', password=True, left='455', top='10',
50
+ width='75', )
51
+ gui.Line(name='line_25_556', height='3', left='24', top='390',
52
+ width='499', )
53
+ gui.Button(label=u'Salir', name='salir', left='440', top='394',
54
+ width='85', onclick='import sys; sys.exit(0)', )
55
+ gui.ComboBox(name=u'url',
56
+ text=u'http://cot.test.arba.gov.ar/TransporteBienes/SeguridadCliente/presentarRemitos.do',
57
+ height='29', left='79', top='42', width='250',
58
+ bgcolor=u'#FFFFFF',
59
+ data_selection=u'http://cot.test.arba.gov.ar/TransporteBienes/SeguridadCliente/presentarRemitos.do',
60
+ fgcolor=u'#4C4C4C',
61
+ items=[u'https://cot.arba.gov.ar/TransporteBienes/SeguridadCliente/presentarRemitos.do', u'http://cot.test.arba.gov.ar/TransporteBienes/SeguridadCliente/presentarRemitos.do'],
62
+ selection=1,
63
+ string_selection=u'http://cot.test.arba.gov.ar/TransporteBienes/SeguridadCliente/presentarRemitos.do', )
64
+ gui.Label(name='lblTest_273_363', height='17', left='234', top='15',
65
+ width='58', text=u'Usuario:', )
66
+ gui.Label(name='lblTest_273', height='17', left='410', top='14',
67
+ width='58', text=u'Clave:', )
68
+ gui.Gauge(name='gauge', height='18', left='20', top='365',
69
+ width='507', )
70
+ gui.Label(id=228, name='lblTest_228', height='17', left='341',
71
+ top='47', width='32', text=u'Carpeta:', )
72
+ with gui.ListView(id=213, name=u'remitos', height='74', left='20',
73
+ top='180', width='510', item_count=0, sort_column=0, ):
74
+ gui.ListColumn(name=u'nro', text=u'N\xb0 \xdanico Remito',
75
+ width=250, )
76
+ gui.ListColumn(name=u'proc', text=u'Procesado', )
77
+ with gui.ListView(name=u'archivos', height='99', left='21', top='77',
78
+ width='509', item_count=0, sort_column=2, ):
79
+ gui.ListColumn(name=u'txt', text='Archivo TXT', width=200, )
80
+ gui.ListColumn(name=u'xml', text='Archivo XML', )
81
+ gui.ListColumn(name=u'cuit', text='CUIT Empresa', )
82
+ gui.ListColumn(name=u'nro', text=u'N\xb0 Comprobante', )
83
+ gui.ListColumn(name=u'md5', text=u'C\xf3digo Integridad', )
84
+ with gui.ListView(id=309, name=u'errores', height='99', left='20',
85
+ top='259', width='510', item_count=0, sort_column=0, ):
86
+ gui.ListColumn(name=u'codigo', text=u'C\xf3digo', width=100, )
87
+ gui.ListColumn(name=u'descripcion', text=u'Descripci\xf3n Error',
88
+ width=400, )
89
+ gui.TextBox(id=488, mask='date', name='fecha',
90
+ left='101', top='10', width='127', enabled=False,
91
+ value=datetime.date(2014, 4, 5), )
92
+ gui.CheckBox(label=u'Fecha:', name=u'filtrar_fecha', height='24',
93
+ left='22', top='11', width='73',
94
+ tooltip=u'filtrar por fecha', )
95
+ gui.Label(id=2084, name='lblTest_228_2084', height='17', left='24',
96
+ top='47', width='32', text=u'URL:', )
97
+ gui.ComboBox(id=961, name=u'carpeta', text=u'datos', height='29',
98
+ left='412', top='42', width='118', bgcolor=u'#FFFFFF',
99
+ data_selection=u'datos', fgcolor=u'#4C4C4C',
100
+ items=[u'datos', u'procesados'], selection=0,
101
+ string_selection=u'datos', )
102
+ gui.Button(label=u'Procesar', name=u'procesar', left='20', top='394',
103
+ tooltip="Presentar el remito en ARBA",
104
+ width='85', default=True, fgcolor=u'#4C4C4C', )
105
+ gui.Button(label=u'Mover Procesados', name=u'mover', left='112',
106
+ top='394', width='166', fgcolor=u'#4C4C4C', )
107
+
108
+ # --- gui2py designer generated code ends ---
109
+
110
+ # get a reference to the Top Level Window (used by designer / events handlers):
111
+ mywin = gui.get("mywin")
112
+ panel = mywin['panel']
113
+
114
+ # Manejo simple de claves:
115
+
116
+ passwd_db = shelve.open("passwd")
117
+
118
+ def getpass(username):
119
+ password = passwd_db.get(str(username))
120
+ if not password:
121
+ password = gui.prompt(message=u"Ingrese contraseña",
122
+ title="Usuario: %s" % username,
123
+ password=True) or ""
124
+ return password
125
+
126
+ def setpass(username, password):
127
+ passwd_db[str(username)] = password
128
+
129
+ def grabar_clave(evt):
130
+ setpass(panel['usuario'].value, panel['clave'].value)
131
+
132
+ # asignar controladores
133
+
134
+ cot = COT()
135
+
136
+ def listar_archivos(evt=None):
137
+ # cargar listado de archivos a procesar (y su correspondiente respuesta):
138
+ lv = panel['archivos']
139
+ lv.clear()
140
+ panel['remitos'].clear()
141
+ panel['errores'].clear()
142
+ # obtengo el fitlro de fecha (si esta habilitado):
143
+ if panel['filtrar_fecha'].value:
144
+ fecha = panel['fecha'].value.strftime("%Y%m%d")
145
+ else:
146
+ fecha = None
147
+ carpeta = panel['carpeta'].text or "."
148
+ for fn in os.listdir(carpeta):
149
+ if fnmatch.fnmatch(fn, 'TB_???????????_*.txt'):
150
+ # filtro por fecha (si esta tildado):
151
+ # TB_20111111112_000000_20080124_000001.txt
152
+ fecha_fn = fn[22:30]
153
+ if fecha and fecha != fecha_fn:
154
+ continue
155
+ txt = fn
156
+ xml = os.path.splitext(fn)[0] + ".xml"
157
+ if not os.path.exists(os.path.join(carpeta, xml)):
158
+ xml = ""
159
+ lv.items[fn] = {'txt': txt, 'xml': xml}
160
+
161
+ def procesar_archivos(evt):
162
+ # establezco la barra de progreso con la cantidad de archivos:
163
+ panel['gauge'].max = len(panel['archivos'].items)
164
+ # recorro los archivos a procesar:
165
+ for i, item in enumerate(panel['archivos'].items):
166
+ panel['gauge'].value = i + 1
167
+ procesar_archivo(item, enviar=True)
168
+
169
+ def cargar_archivo(evt):
170
+ # obtengo y proceso el archivo seleccionado:
171
+ item = evt.target.get_selected_items()[0]
172
+ procesar_archivo(item)
173
+
174
+ def abrir_archivo(evt):
175
+ # obtengo y proceso el archivo seleccionado:
176
+ item = evt.target.get_selected_items()[0]
177
+ fn = os.path.join(panel['carpeta'].text, item['txt'])
178
+ try:
179
+ os.startfile(fn)
180
+ except AttributeError:
181
+ import subprocess
182
+ subprocess.run(["gedit", fn], check=False)
183
+
184
+ def procesar_archivo(item, enviar=False):
185
+ "Enviar archivo a ARBA y analizar la respuesta"
186
+
187
+ # establezco credenciales:
188
+ cuit = item['txt'][3:14]
189
+ cot.Usuario = panel['usuario'].value = cuit
190
+ cot.Password = panel['clave'].value = getpass(cuit)
191
+ cot.Conectar(panel['url'].text, trace=True)
192
+
193
+ # obtengo la ruta al archivo de texto y xml
194
+ carpeta = panel['carpeta'].text
195
+ fn = os.path.join(carpeta, item['txt'])
196
+ xml = item['xml']
197
+ if xml:
198
+ xml = os.path.join(carpeta, xml)
199
+ elif not enviar:
200
+ return
201
+
202
+ # llamada al webservice:
203
+ cot.PresentarRemito(fn, testing=xml)
204
+
205
+ # grabo el xml devuelto:
206
+ if not xml:
207
+ xml = os.path.splitext(fn)[0] + ".xml"
208
+ with open(xml, "w") as f:
209
+ f.write(cot.XmlResponse)
210
+
211
+ if cot.Excepcion and enviar:
212
+ gui.alert(cot.Traceback, cot.Excepcion)
213
+
214
+ if cot.TipoError and enviar:
215
+ gui.alert(cot.MensajeError, "Error %s: %s" % (cot.TipoError, cot.CodigoError))
216
+
217
+ # actualizo los datos devueltos en el listado
218
+ item['cuit'] = cot.CuitEmpresa
219
+ item['nro'] = cot.NumeroComprobante
220
+ item['md5'] = cot.CodigoIntegridad
221
+ #assert item['txt'] == cot.NombreArchivo
222
+
223
+ # limpio, enumero y agrego los remitos para el archivo seleccionado:
224
+ remitos = panel['remitos']
225
+ item['remitos'] = []
226
+ panel['errores'].items = []
227
+ remitos.items = []
228
+ i = 0
229
+ while cot.LeerValidacionRemito():
230
+ print "REMITO", i
231
+ errores = []
232
+ remito = {'nro': cot.NumeroUnico, 'proc': cot.Procesado,
233
+ 'errores': errores}
234
+ remitos.items[i] = remito
235
+ item['remitos'].append(remito)
236
+ i += 1
237
+ while cot.LeerErrorValidacion():
238
+ print "Error Validacion:", "|", cot.CodigoError, "|", cot.MensajeError
239
+ errores.append({'codigo': cot.CodigoError,
240
+ 'descripcion': cot.MensajeError})
241
+
242
+ def cargar_errores(evt):
243
+ # obtengo el remito seleccionado:
244
+ item = evt.target.get_selected_items()[0]
245
+ # limpio, enumero y agrego los errores para el remito seleccionado:
246
+ errores = panel['errores']
247
+ errores.items = []
248
+ for i, error in enumerate(item['errores']):
249
+ print i, error
250
+ errores.items[i] = error
251
+
252
+ def filtro_fecha(evt):
253
+ panel['fecha'].enabled = evt.target.value
254
+ listar_archivos()
255
+
256
+
257
+ def mover_archivos(evt=None):
258
+ carpeta = panel['carpeta'].text
259
+ if carpeta != "datos":
260
+ gui.alert("No se puede mover archivos de carpeta %s" % carpeta)
261
+
262
+ i = 0
263
+ print panel['archivos'].items
264
+ for item in panel['archivos'].items:
265
+ procesado = all([remito.get('proc', 'NO') == 'SI'
266
+ for remito in item.get('remitos', [])])
267
+ if procesado and item.get('remitos'):
268
+ for fn in (item['txt'], item['xml']):
269
+ fn0 = os.path.join("datos", fn)
270
+ fn1 = os.path.join("procesados", fn)
271
+ try:
272
+ os.rename(fn0, fn1)
273
+ i += 1
274
+ except Exception, e:
275
+ gui.alert(unicode(e), "No se puede mover %s" % fn)
276
+ gui.alert("Se movieron: %s archivos" % i)
277
+ listar_archivos()
278
+
279
+
280
+ panel['archivos'].onitemselected = cargar_archivo
281
+ panel['archivos'].onmousedclick = abrir_archivo
282
+ panel['remitos'].onitemselected = cargar_errores
283
+ panel['filtrar_fecha'].onclick = filtro_fecha
284
+ panel['fecha'].onchange = listar_archivos
285
+ panel['carpeta'].onchange = listar_archivos
286
+ panel['mover'].onclick = mover_archivos
287
+ panel['procesar'].onclick = procesar_archivos
288
+ panel['clave'].onchange = grabar_clave
289
+
290
+
291
+ if __name__ == "__main__":
292
+ mywin.show()
293
+ mywin.title = u"%s - %s" % (mywin.title, cot.Version.decode("latin1"))
294
+ mywin['statusbar'].text = ""
295
+ listar_archivos()
296
+ gui.main_loop()
297
+ passwd_db.close()
298
+
app/pyafipws/datos/TB_20111111112_000000_20080124_000001.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 01|20111111112
2
+ 02|20080124|91 R999900068148|20080124| |E|0| | |30682115722|COMPUMUNDO S.A.| 0|Ruta Prov | |S/N| | | |1200|PUERTO DE ESCOBAR|B| |NO| 23246414254|COMPUMUNDO S.A. | 0|San Martin 5797| |S/N| | | |1766|TABLADA| B| 20045162673| | | | | | |0
3
+ 03|847150|3|100|23891|COMP. SP-3960 VP|UNI DAD| 100
4
+ 03|852110|3|100|23763|VIDEO CAMARA GR-D750|UNI DAD|100
5
+ 03|852520|3|500|23666|PERS MOTO K1 SILVER + MEM|UNI DAD| 500
6
+ 03|852520|3|700|24159|PERSONAL NOKIA 5200 BLUE|UNI DAD| 700
7
+ 03|852520|3|200|24182|PERS S.ERI C W200 BLAC+MEM|UNI DAD|200
8
+ 03|852390|3|500|23348|DVD+R X10 4.7GB 10DPR120|UNI DAD|500
9
+ 03|847170|3|100|23842|HDD 250GB 7200RPM|UNI DAD| 100
10
+ 03|847160|3|500|23896|GAME PAD EUGA 10 BLUE B/W| UNI DAD| 500
11
+ 03|847330|3|400|22891|CART TWI NPACK 21 NEGRO|UNI DAD| 400
12
+ 03|850650|3|500|22693|PI LAS ALCALI NA AA X 4|UNI DAD| 500
13
+ 03|852431|3|200|23846|NORTON ANTIVIRUS 2007|UNI DAD| 200
14
+ 03|847170|3|400|23122|DVDRW 16X/18X DRU830A NEG|UNI DAD| 400
15
+ 03|847170|3|1000|23914|DVDRW AOPEN 20X BOX|UNI DAD| 1000
16
+ 03|852190|3|100|24248|REPROD DVD DVD-AVD800|UNI DAD| 100
17
+ 03|851822|3|100|23621|J.PARL HT- 685|UNI DAD| 100
18
+ 04| 1