diff --git a/.gitignore b/.gitignore index 8a357b0..c77d198 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ __pycache__/ *.pyc certbot web-resources +.coverage +password|* diff --git a/README.md b/README.md index 6abe416..252c34f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,171 @@ -# ЭВОСИНК (EvoSync) +# EvoSync -Сервис автоматической синхронизации товарного каталога из [Эвотор](https://evotor.ru) в магазин [ВКонтакте](https://vk.com). +Web service for syncing a product catalog from Evotor POS → VK Market. Users connect their Evotor account and a VK page; products from the cash register then appear automatically in the VK store. + +--- + +## Architecture + +``` + ┌─────────────────────────────────────────┐ + │ Docker Compose │ + │ │ + Browser / Evotor ─────► web :8000 (FastAPI + Uvicorn) │ + :8080 │ │ │ + │ ├── MariaDB :3306 (primary DB) │ + │ ├── Redis :6379 (Celery broker) │ + │ │ │ + │ ├── worker (Celery worker) │ + │ ├── beat (Celery beat scheduler) │ + │ └── flower :5555 (queue monitor) │ + └─────────────────────────────────────────┘ +``` + +### Services + +| Service | Image / Dockerfile | Purpose | External port | +|----------|---------------------|----------------------------------------------|---------------| +| `web` | `Dockerfile.web` | FastAPI app, runs Alembic migrations on start | 8080 → 8000 | +| `worker` | `Dockerfile.web` | Celery worker (sync, health, notifications…) | — | +| `beat` | `Dockerfile.web` | Celery Beat — periodic task scheduler | — | +| `flower` | `Dockerfile.web` | Celery queue monitoring UI | 5555 | +| `db` | `mariadb:11.4` | Primary relational database | — | +| `redis` | `redis:7-alpine` | Celery broker and result backend | — | + +### Stack + +- **Python 3.12**, FastAPI 0.115, Uvicorn +- **SQLAlchemy 2** + Alembic, MariaDB (PyMySQL) +- **Celery 5** + Redis +- **Jinja2** — server-side HTML rendering (`web/templates/`) +- **Pydantic Settings** — configuration from env vars / `.env` +- bcrypt — password hashing +- python-json-logger — structured JSON logs to stdout + +--- + +## Database Schema + +| Table | Purpose | +|----------------------|--------------------------------------------------------------------------------| +| `users` | User accounts (roles: system / admin / user; statuses: pending / active / suspended) | +| `evotor_connections` | User ↔ Evotor link (access_token, api_token returned to Evotor webhooks) | +| `vk_connections` | User ↔ VK OAuth link | +| `sync_configs` | Per-user sync settings | +| `sync_filters` | Product / group inclusion/exclusion filters | +| `cached_stores` | Cached list of Evotor stores | +| `cached_groups` | Cached Evotor product groups | +| `cached_products` | Cached Evotor product catalog | +| `roles` | RBAC roles | +| `permissions` | RBAC permissions | +| `role_permissions` | M2M: role ↔ permission | +| `user_roles` | M2M: user ↔ role | + +--- + +## Routes + +### Public / Authentication + +| Method | Path | Description | +|----------|--------------------|----------------------------------------| +| GET | `/` | Redirects to `/profile` or `/login` | +| GET | `/health` | Health check (JSON) | +| GET/POST | `/register` | User registration | +| GET | `/confirm-email` | Email confirmation via token | +| GET | `/resend-confirm` | Resend confirmation email | +| GET/POST | `/login` | Login | +| GET | `/logout` | Logout | +| GET/POST | `/forgot-password` | Request password reset | +| GET/POST | `/reset-password` | Reset password via token | +| GET/POST | `/invite` | Complete registration via invite link | + +### Profile (requires session) + +| Method | Path | Description | +|----------|----------------------------|----------------------| +| GET | `/profile` | View profile | +| GET/POST | `/profile/edit` | Edit profile | +| GET/POST | `/profile/change-password` | Change password | +| GET/POST | `/profile/delete` | Delete account | + +### Admin panel (`/admin`, roles: admin / system) + +| Method | Path | Description | +|--------|------------------------------------|---------------------------| +| GET | `/admin/users` | User list | +| GET | `/admin/users/{id}` | User detail | +| POST | `/admin/users/{id}/activate` | Activate user | +| POST | `/admin/users/{id}/suspend` | Suspend user | +| POST | `/admin/users/{id}/reset-password` | Reset user password | +| POST | `/admin/users/{id}/send-invite` | Send invite email | +| POST | `/admin/users/{id}/edit` | Edit user data | +| POST | `/admin/users/{id}/delete` | Delete user | +| GET | `/admin/roles` | Roles and permissions | +| POST | `/admin/roles/{id}/permissions` | Update role permissions | + +### Evotor Webhooks (Bearer `EVOTOR_WEBHOOK_SECRET`) + +| Method | Path | Description | +|--------|----------------|------------------------------------------------------------------| +| POST | `/user/create` | Evotor creates/links a user and receives an api_token | +| POST | `/user/verify` | Evotor verifies user credentials and receives an api_token | +| POST | `/user/token` | Evotor delivers its own access_token for a user | + +### API Docs + +| Path | Description | +|----------|-------------| +| `/docs` | Swagger UI | +| `/redoc` | ReDoc | + +--- + +## Configuration + +All settings are read from environment variables or a `.env` file: + +| Variable | Default | Description | +|------------------------------------|-------------------------------------|--------------------------------------| +| `DATABASE_URL` | `mysql+pymysql://…@db:3306/evosync` | MariaDB connection string | +| `REDIS_URL` | `redis://redis:6379/0` | Redis connection string | +| `SECRET_KEY` | `change-me-in-production` | Session signing key | +| `BASE_URL` | `http://localhost:8000` | Public URL of the service | +| `EVOTOR_APP_ID` | — | Evotor application ID | +| `EVOTOR_WEBHOOK_SECRET` | — | Bearer secret for webhook endpoints | +| `JIVOSITE_WIDGET_ID` | — | JivoSite widget ID | +| `VK_DEFAULT_PHOTO_PATH` | `/app/default_product.png` | Fallback image path for VK products | +| `VK_API_VERSION` | `5.199` | VK API version | +| `CATALOG_REFRESH_INTERVAL_SECONDS` | `3600` | Catalog cache refresh interval | +| `INVITE_EXPIRE_HOURS` | `48` | Invite link TTL in hours | +| `EMAIL_PROVIDER` | `console` | Email provider (console / smtp / …) | +| `SMS_PROVIDER` | `console` | SMS provider | +| `FLOWER_USER` / `FLOWER_PASSWORD` | `admin` / `changeme` | Basic Auth credentials for Flower | + +--- + +## Running + +```bash +cp .env.example .env # fill in your values +docker compose up -d --build +``` + +App is available at `http://localhost:8080`. +Flower (queue monitor) at `http://localhost:5555`. + +### Development + +```bash +pip install -r requirements.txt +alembic upgrade head +uvicorn web.main:app --reload --port 8000 +``` + +--- + +## Tests + +```bash +pytest --cov=web +``` diff --git a/requirements.txt b/requirements.txt index ed29ea9..3226d72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ jinja2==3.1.4 sqlalchemy==2.0.36 alembic==1.14.0 pymysql==1.1.1 +itsdangerous>=2.1.0 cryptography>=44.0.0 bcrypt>=4.2.1 pydantic-settings==2.6.1 diff --git a/scripts/create_admin.py b/scripts/create_admin.py new file mode 100644 index 0000000..e2c71c6 --- /dev/null +++ b/scripts/create_admin.py @@ -0,0 +1,78 @@ +""" +Create (or promote) an admin user with all permissions. + +Usage: + python -m scripts.create_admin --email admin@example.com --password secret + python -m scripts.create_admin --email admin@example.com --password secret --role system +""" +import argparse +import sys + +from web.auth.password import hash_password +from web.database import SessionLocal +from web.models.rbac import Permission, Role, UserRole, role_permissions +from web.models.user import User, UserRoleEnum, UserStatusEnum + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--email", required=True) + parser.add_argument("--password", required=True) + parser.add_argument("--first-name", default="Admin") + parser.add_argument("--last-name", default="") + parser.add_argument("--phone", default="") + parser.add_argument("--role", default="system", choices=["admin", "system"]) + args = parser.parse_args() + + db = SessionLocal() + try: + user = db.query(User).filter(User.email == args.email).first() + if user: + user.role = UserRoleEnum(args.role) + user.status = UserStatusEnum.active + user.is_email_confirmed = True + user.password_hash = hash_password(args.password) + print(f"Updated existing user: {args.email} → role={args.role}") + else: + user = User( + first_name=args.first_name, + last_name=args.last_name, + email=args.email, + phone=args.phone, + password_hash=hash_password(args.password), + role=UserRoleEnum(args.role), + status=UserStatusEnum.active, + is_email_confirmed=True, + ) + db.add(user) + db.flush() + print(f"Created user: {args.email} role={args.role}") + + # Assign all permissions via the system role + system_role = db.query(Role).filter(Role.name == args.role).first() + if system_role: + existing = db.query(UserRole).filter( + UserRole.user_id == user.id, + UserRole.role_id == system_role.id, + ).first() + if not existing: + db.add(UserRole(user_id=user.id, role_id=system_role.id)) + + db.commit() + + # Report permissions this user now has + perms = ( + db.query(Permission.name) + .join(role_permissions, Permission.id == role_permissions.c.permission_id) + .join(UserRole, UserRole.role_id == role_permissions.c.role_id) + .filter(UserRole.user_id == user.id) + .all() + ) + print("Permissions:", [p.name for p in perms] or "(inherited via role)") + + finally: + db.close() + + +if __name__ == "__main__": + main()