diff --git a/.env b/.env new file mode 100644 index 00000000..30c0dc08 --- /dev/null +++ b/.env @@ -0,0 +1,25 @@ +# True for development, False for production +DEBUG=True + +# Flask ENV +FLASK_APP=run.py +FLASK_DEBUG=1 + +# If not provided, a random one is generated +# SECRET_KEY= + +# If DEBUG=False (production mode) +# DB_ENGINE=mysql +# DB_NAME=appseed_db +# DB_HOST=localhost +# DB_PORT=3306 +# DB_USERNAME=appseed_db_usr +# DB_PASS= + +# SOCIAL AUTH Github +# GITHUB_ID=YOUR_GITHUB_ID +# GITHUB_SECRET=YOUR_GITHUB_SECRET + +# SOCIAL AUTH Google +# GOOGLE_ID=YOUR_GOOGLE_ID +# GOOGLE_SECRET=YOUR_GOOGLE_SECRET diff --git a/.gitignore b/.gitignore index 5cc061e5..e1457b89 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ __pycache__/ # database & logs *.db -*.sqlite3 +#*.sqlite3 *.log # venv @@ -31,6 +31,9 @@ apps/static/assets/node_modules apps/static/assets/yarn.lock apps/static/assets/.temp -migrations -.env -.env__ + +#migrations + +node_modules/ +README_bk.md +yarn.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 9323089c..0add02f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,60 @@ # Change Log +## [1.0.19] 2025-04-01 +### Changes + +- Update RM (minor) + +## [1.0.18] 2025-03-14 +### Changes + +- Update RM Links for [App Generator](https://app-generator.dev/) + - [Flask Soft UI Dashboard](https://app-generator.dev/product/soft-ui-dashboard/flask/) - `Product Page` + - [Flask Soft UI Dashboard](https://flask-soft.onrender.com) - `LIVE Demo` + - [Flask Soft UI Dashboard](https://app-generator.dev/docs/products/flask/soft-ui-dashboard/index.html) - `Complete Information` and Support Links +- Added [Flask Generator](https://app-generator.dev/tools/flask-generator/) Link + - Select the preferred design + - (Optional) Design Database: edit models and fields + - (Optional) Edit the fields for the extended user model + - (Optional) Enable OAuth for GitHub + - (Optional) Add Celery (async tasks) + - (Optional) Enable Dynamic Tables Module + - Docker Scripts + - Render CI/Cd Scripts + +## [1.0.17] 2024-05-18 +### Changes + +- Updated DOCS (readme) + - [Custom Development](https://appseed.us/custom-development/) Section + - [CI/CD Assistance for AWS, DO](https://appseed.us/terms/#section-ci-cd) + +## [1.0.16] 2024-03-09 +### Changes + +- Update [Custom Development](https://appseed.us/custom-development/) Section + - New Pricing: `$3,999` + +## [1.0.15] 2023-10-07 +### Changes + +- Update Dependencies + +## [1.0.14] 2023-01-02 +### Changes + +- `DOCS Update` (readme) + - [Flask Soft Dashboard - Go LIVE](https://www.youtube.com/watch?v=EamoPo4iRgk) (`video presentation`) + +## [1.0.13] 2023-01-02 +### Changes + +- Deployment-ready for Render (CI/CD) + - `render.yaml` + - `build.sh` +- `DB Management` Improvement + - `Silent fallback` to **SQLite** + ## [1.0.12] 2022-10-01 ### Improvements diff --git a/Dockerfile b/Dockerfile index f4c0b323..151fc24e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9 +FROM python:3.10 # set environment variables ENV PYTHONDONTWRITEBYTECODE 1 @@ -16,7 +16,6 @@ COPY env.sample .env COPY . . -RUN flask db init RUN flask db migrate RUN flask db upgrade RUN flask gen_api diff --git a/Procfile b/Procfile deleted file mode 100644 index a443d0f2..00000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: gunicorn run:app --log-file=- diff --git a/README.md b/README.md index bfd151bd..bac45bdb 100644 --- a/README.md +++ b/README.md @@ -1,340 +1,77 @@ -# [Soft UI Dashboard Flask](https://appseed.us/product/soft-ui-dashboard/flask/) +# [Flask Soft UI Dashboard](https://app-generator.dev/product/soft-ui-dashboard/flask/) -Open-source **Flask Dashboard** generated by `AppSeed` op top of a modern design. Designed for those who like bold elements and beautiful websites, **[Soft UI Dashboard](https://appseed.us/generator/soft-ui-dashboard/)** is ready to help you create stunning websites and webapps. **Soft UI Dashboard** is built with over 70 frontend individual elements, like buttons, inputs, navbars, nav tabs, cards, or alerts, giving you the freedom of choosing and combining. +Open-source **Flask Starter with Soft UI Dashboard Design**, an open-source iconic `Bootstrap` design. +The product is designed to deliver the best possible user experience with highly customizable feature-rich pages. -- 👉 [Soft UI Dashboard Flask](https://appseed.us/product/soft-ui-dashboard/flask/) - Product page -- 👉 [Soft UI Dashboard Flask](https://flask-soft-ui-dashboard.appseed-srv1.com/) - LIVE Demo -- 👉 [Complete documentation](https://docs.appseed.us/products/flask-dashboards/soft-ui-dashboard) - `Learn how to use and update the product` +- 👉 [Flask Soft UI Dashboard](https://app-generator.dev/product/soft-ui-dashboard/flask/) - `Product Page` +- 👉 [Flask Soft UI Dashboard](https://flask-soft.onrender.com/) - `LIVE Demo` +- 👉 [Flask Soft UI Dashboard](https://app-generator.dev/docs/products/flask/soft-ui-dashboard/index.html) - `Complete Information` and Support Links + - [Getting Started with Flask](https://app-generator.dev/docs/technologies/flask/index.html) - a `comprehensive tutorial` + - `Configuration`: Install Tailwind/Flowbite, Prepare Environment, Setting up the Database + - `Start with Docker` + - `Manual Build` + - `Start the project` + - `Deploy on Render`
-> Roadmap & Features +## Features -| Status | Item | info | -| --- | --- | --- | -| ✅ | **Up-to-date Dependencies** | Tested with Django `v3.2.x`, `v4.x` | -| ✅ | **UI Kit** | `Bootstrap 5`, `Dark-Mode` (persistent) | -| ✅ | **Deployment** | `Docker`, [HEROKU](#deploy-app-with-heroku) | -| ✅ | **Persistence** | `SQLite`, `MySql` | -| ✅ | **Authentication** | Basic, `OAuth` via **AllAuth** for Github | -| ✅ | **[API Generator](#api-generator)** | Secure API via `Flask-restX` | -| ❌ | **Dynamic DataTables** | `Server-side` pagination, `Search`, Export | -| ❌ | **Stripe Payments** | `One-Time` and `Subscriptions` | -| ❌ | **Async Tasks** | via `Celery` | +- Simple, Easy-to-Extend codebase, [Blueprint Pattern](https://app-generator.dev/blog/flask-blueprints-a-developers-guide/) +- Up-to-date Dependencies +- [Soft UI Dashboard](https://app-generator.dev/docs/templates/bootstrap/soft-ui-dashboard.html) Integration +- [Bootstrap](https://app-generator.dev/docs/templates/bootstrap/index.html) Styling +- Auth: Session Based, GitHub, Google +- Celery Beat +- DB Persistence: SQLite (default), + - Easy switch to MySql/MariaDB, PgSql +- Dynamic DataTables - manage data without coding +- CI/CD integration for [Render](https://app-generator.dev/docs/deployment/render/index.html) +- Deployment: Docker, Flask-Minify - -> Something is missing? Submit a new `product feature request` using the [issues tracker](https://github.com/app-generator/flask-soft-ui-dashboard/issues). - -
- -![Soft UI Dashboard - Full-Stack Starter generated by AppSeed.](https://user-images.githubusercontent.com/51070104/175773323-3345d618-0e78-4c85-83fc-f495dc3f0bb0.png) - -
- - -## ✨ Start the app in Docker - -> **Step 1** - Download the code from the GH repository (using `GIT`) - -```bash -$ git clone https://github.com/app-generator/flask-soft-ui-dashboard.git -$ cd flask-soft-ui-dashboard -``` - -
- -> **Step 2** - Start the APP in `Docker` - -```bash -$ docker-compose up --build -``` - -Visit `http://localhost:5085` in your browser. The app should be up & running. - -
- -## ✨ Create a new `.env` file using sample `env.sample` - -The meaning of each variable can be found below: - -- `DEBUG`: if `True` the app runs in develoment mode - - For production value `False` should be used -- `ASSETS_ROOT`: used in assets management - - default value: `/static/assets` -- `OAuth` via Github - - `GITHUB_ID`= - - `GITHUB_SECRET`= +![Soft UI Dashboard - Full-Stack Starter generated by AppSeed.](https://github.com/user-attachments/assets/9510c443-4615-4856-b9c4-f00875134f7d)
-## ✨ Manual Build +## Deploy LIVE -> Download the code +> One-click deploy (requires already having an account). -```bash -$ git clone https://github.com/app-generator/flask-soft-ui-dashboard.git -$ cd flask-soft-ui-dashboard -``` +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy)
-### 👉 Set Up for `Unix`, `MacOS` - -> Install modules via `VENV` - -```bash -$ virtualenv env -$ source env/bin/activate -$ pip3 install -r requirements.txt -``` - -
- -> Set Up Flask Environment - -```bash -$ export FLASK_APP=run.py -$ export FLASK_ENV=development -``` - -
- -> Start the app - -```bash -$ flask run -// OR -$ flask run --cert=adhoc # For HTTPS server -``` - -At this point, the app runs at `http://127.0.0.1:5000/`. - -
- -### 👉 Set Up for `Windows` - -> Install modules via `VENV` (windows) - -``` -$ virtualenv env -$ .\env\Scripts\activate -$ pip3 install -r requirements.txt -``` - -
- -> Set Up Flask Environment - -```bash -$ # CMD -$ set FLASK_APP=run.py -$ set FLASK_ENV=development -$ -$ # Powershell -$ $env:FLASK_APP = ".\run.py" -$ $env:FLASK_ENV = "development" -``` - -
- -> Start the app - -```bash -$ flask run -// OR -$ flask run --cert=adhoc # For HTTPS server -``` - -At this point, the app runs at `http://127.0.0.1:5000/`. - -
- -## API Generator - -This module helps to generate secure APIs using `Flask-restX` via a simple workflow: - -- Edit/add your model in `apps/models.py` -- Migrate the database: - -```bash -$ flask db init # this should be executed only once -$ flask db migrate # Generates the SQL -$ flask db upgrade # Apply changes -``` +## [Flask Soft UI Dashboard PRO](https://appseed.us/product/soft-ui-dashboard-pro/flask/) -- Update Configuration: - - `apps/config .py`, section `API_GENERATOR` -- Generate the API code: - - `$ flask gen_api` # the new code is saved in `apps/api` -- Access the API in the browser: - - `/api/MODEL_NAME/` +> The premium version provides more features, priority on support, and is more often updated. -The API is secured using the JWT tocken provided by `/login/jwt/` request (username & password should be provided). +- [Soft UI Dashboard PRO Flask](https://appseed.us/product/soft-ui-dashboard-pro/flask/) - `product page` +- `Enhanced UI` - more pages and components +- `Priority` on support -- GET requests are public (GET all, get Item) -- Mutating requests are protected by token generated based on the user credentials (`username`, `pass`). - -> Two POSTMAN Collections are provided in the `media` directory: - -- [Books API](./media/api-books.postman_collection) - that uses PORT **5000* for the api -- [Books API 2](./media/api-books-docker.postman_collection) - that uses PORT **5085* for the api (default port in Docker) - -In case both port are unusable in your environment, feel free to edit the files before POSTMAN import. - -
- -### 👉 Create Users - -By default, the app redirects guest users to authenticate. In order to access the private pages, follow this set up: - -- Start the app via `flask run` -- Access the `registration` page and create a new user: - - `http://127.0.0.1:5000/register` -- Access the `sign in` page and authenticate - - `http://127.0.0.1:5000/login` - -
- -## ✨ Code-base structure - -The project is coded using blueprints, app factory pattern, dual configuration profile (development and production) and an intuitive structure presented bellow: - -```bash -< PROJECT ROOT > - | - |-- apps/ - | | - | |-- home/ # A simple app that serve HTML files - | | |-- routes.py # Define app routes - | | - | |-- authentication/ # Handles auth routes (login and register) - | | |-- routes.py # Define authentication routes - | | |-- models.py # Defines models - | | |-- forms.py # Define auth forms (login and register) - | | - | |-- static/ - | | |-- # CSS files, Javascripts files - | | - | |-- templates/ # Templates used to render pages - | | |-- includes/ # HTML chunks and components - | | | |-- navigation.html # Top menu component - | | | |-- sidebar.html # Sidebar component - | | | |-- footer.html # App Footer - | | | |-- scripts.html # Scripts common to all pages - | | | - | | |-- layouts/ # Master pages - | | | |-- base-fullscreen.html # Used by Authentication pages - | | | |-- base.html # Used by common pages - | | | - | | |-- accounts/ # Authentication pages - | | | |-- login.html # Login page - | | | |-- register.html # Register page - | | | - | | |-- home/ # UI Kit Pages - | | |-- index.html # Index page - | | |-- 404-page.html # 404 page - | | |-- *.html # All other pages - | | - | config.py # Set up the app - | __init__.py # Initialize the app - | - |-- requirements.txt # App Dependencies - | - |-- .env # Inject Configuration via Environment - |-- run.py # Start the app - WSGI gateway - | - |-- ************************************************************************ -``` - -
- -## Deploy APP with HEROKU - -> The set up - -- [Create a FREE account](https://signup.heroku.com/) on Heroku platform -- [Install the Heroku CLI](https://devcenter.heroku.com/articles/getting-started-with-python#set-up) that match your OS: Mac, Unix or Windows -- Open a terminal window and authenticate via `heroku login` command -- Clone the sources and push the project for LIVE deployment - -
- -> 👉 **Step 1** - Download the code from the GH repository (using `GIT`) - -```bash -$ git clone https://github.com/app-generator/flask-soft-ui-dashboard.git -$ cd flask-soft-ui-dashboard -``` - -
- -> 👉 **Step 2** - Connect to `HEROKU` using the console - -```bash -$ # This will open a browser window - click the login button (in browser) -$ heroku login -``` -
- -> 👉 **Step 3** - Create the `HEROKU` project - -```bash -$ heroku create -``` +![Soft UI Dashboard PRO - Starter generated by AppSeed.](https://user-images.githubusercontent.com/51070104/170829870-8acde5af-849a-4878-b833-3be7e67cff2d.png)
-> 👉 **Step 4** - Access the HEROKU dashboard and update the environment variables. This step is mandatory because HEROKU ignores the `.env`. - -- `DEBUG`=True -- `FLASK_APP`=run.py -- `ASSETS_ROOT`=/static/assets +## `Customize` with [Flask Generator](https://app-generator.dev/tools/flask-generator/) -![AppSeed - HEROKU Set UP](https://user-images.githubusercontent.com/51070104/171815176-c1ca7681-38cc-4edf-9ecc-45f93621573d.jpg) +- Access the [Flask Generator](https://app-generator.dev/tools/flask-generator/) +- Select the preferred design +- (Optional) Design Database: edit models and fields +- (Optional) Edit the fields for the extended user model +- (Optional) Enable OAuth for GitHub +- (Optional) Add Celery (async tasks) +- (Optional) Enable Dynamic Tables Module +- Docker Scripts +- Render CI/Cd Scripts -
+**The generated Flask project is available as a ZIP Archive and also uploaded to GitHub.** -> 👉 **Step 5** - Push Sources to `HEROKU` +![Flask Generator - Flask App Generator - User Interface for choosing the Design](https://github.com/user-attachments/assets/fbf73fc0-e9a1-4f01-86a8-aa8be55413b5) -```bash -$ git push heroku HEAD:master -``` +![Flask App Generator - User Interface for Edit the Extended User Model](https://github.com/user-attachments/assets/138b9816-4f2e-454f-84f2-7409969b8548)
-> 👉 **Step 6** - Visit the app in the browser - -```bash -$ heroku open -``` - -At this point, the APP should be up & running. - -
- -> 👉 **Step 7** (Optional) - Visualize `HEROKU` logs - -```bash -$ heroku logs --tail -``` - -
- -## PRO Version - -> For more components, pages and priority on support, feel free to take a look at this amazing starter: - -Soft UI Dashboard is a premium Bootstrap 5 Design now available for download in Flask. Made of hundred of elements, designed blocks, and fully coded pages, Soft UI Dashboard PRO is ready to help you create stunning websites and web apps. - -- 👉 [Soft UI Dashboard PRO Flask](https://appseed.us/product/soft-ui-dashboard-pro/flask/) - product page - - ✅ `Enhanced UI` - more pages and components - - ✅ `Priority` on support - -
- -![Soft UI Dashboard PRO - Starter generated by AppSeed.](https://user-images.githubusercontent.com/51070104/170829870-8acde5af-849a-4878-b833-3be7e67cff2d.png) - -
- --- -[Soft UI Dashboard Flask](https://appseed.us/product/soft-ui-dashboard/flask/) - Open-source starter generated by **[AppSeed Generator](https://appseed.us/generator/)**. +[Flask Soft UI Dashboard](https://app-generator.dev/product/soft-ui-dashboard/flask/) - Open-Source **Flask** Starter provided by [App Generator](https://app-generator.dev) diff --git a/api_generator/commands.py b/api_generator/commands.py deleted file mode 100644 index 90ec0358..00000000 --- a/api_generator/commands.py +++ /dev/null @@ -1,26 +0,0 @@ -import importlib - -import click -import api_generator.manager as manager -from flask.cli import with_appcontext -from apps.config import API_GENERATOR - - -@click.command(name="gen_api") -@with_appcontext -def gen_api(): - for model in API_GENERATOR.values(): - try: - models = importlib.import_module("apps.models") - ModelClass = getattr(models, model) - ModelClass.query.all() - except Exception as e: - print(f"Generation API failed because: {str(e)}") - return - - try: - manager.generate_forms_file() - manager.generate_routes_file() - print("APIs have been generated successfully.") - except Exception as e: - print(f"Generation API failed because: {str(e)}") diff --git a/api_generator/forms/base_form b/api_generator/forms/base_form deleted file mode 100644 index 6c863f67..00000000 --- a/api_generator/forms/base_form +++ /dev/null @@ -1,3 +0,0 @@ -class {model_name}Form(ModelForm): - class Meta: - model = {model_name} diff --git a/api_generator/forms/base_imports b/api_generator/forms/base_imports deleted file mode 100644 index d2c081b7..00000000 --- a/api_generator/forms/base_imports +++ /dev/null @@ -1,4 +0,0 @@ -from apps.models import * - - -ModelForm = model_form_factory(Form) diff --git a/api_generator/forms/forms_structure b/api_generator/forms/forms_structure deleted file mode 100644 index 4e3f695d..00000000 --- a/api_generator/forms/forms_structure +++ /dev/null @@ -1,5 +0,0 @@ -{library_imports} - -{project_imports} - -{forms} diff --git a/api_generator/forms/library_imports b/api_generator/forms/library_imports deleted file mode 100644 index 30447ecb..00000000 --- a/api_generator/forms/library_imports +++ /dev/null @@ -1,2 +0,0 @@ -from wtforms import Form -from wtforms_alchemy import model_form_factory diff --git a/api_generator/manager.py b/api_generator/manager.py deleted file mode 100644 index b25a35ae..00000000 --- a/api_generator/manager.py +++ /dev/null @@ -1,67 +0,0 @@ -from apps.config import API_GENERATOR - - -def generate_forms_file(): - with open('api_generator/forms/forms_structure', 'r') as forms_structure_file: - forms_structure = forms_structure_file.read() - - with open('api_generator/forms/library_imports', 'r') as library_imports_file: - library_imports = library_imports_file.read() - - with open('api_generator/forms/base_imports', 'r') as base_imports_file: - base_imports = base_imports_file.read() - - with open('api_generator/forms/base_form', 'r') as base_form_file: - base_form = base_form_file.read() - - project_imports = base_imports.format(models_name=", ".join(API_GENERATOR.values())) - forms = '\n\n'.join(base_form.format(model_name=model_name) for model_name in API_GENERATOR.values()) - generation = forms_structure.format( - library_imports=library_imports, - project_imports=project_imports, - forms=forms - ) - - with open('apps/api/forms.py', 'w') as forms_py: - forms_py.write(generation) - - return generation - - -def generate_routes_file(): - with open('api_generator/routes/routes_structure', 'r') as routes_structure_file: - routes_structure = routes_structure_file.read() - - with open('api_generator/routes/library_imports', 'r') as library_imports_file: - library_imports = library_imports_file.read() - - with open('api_generator/routes/base_imports', 'r') as base_imports_file: - base_imports = base_imports_file.read() - - with open('api_generator/routes/base_route', 'r') as base_routes_file: - base_routes = base_routes_file.read() - project_imports = base_imports.format( - models_name=', '.join(API_GENERATOR.values()), - forms_name=', '.join(list(map(lambda model_name: f'{model_name}Form', API_GENERATOR.values()))) - ) - routes = '\n\n'.join(base_routes.format( - form_name=f'{model_name}Form', - model_name=model_name, - endpoint=endpoint - ) for endpoint, model_name in API_GENERATOR.items()) - - generation = routes_structure.format( - library_imports=library_imports, - project_imports=project_imports, - routes=routes - ) - - with open('apps/api/routes.py', 'w') as routes_py: - routes_py.write(generation) - - return generation - - -if __name__ == '__main__': - generate_routes_file() - generate_forms_file() \ No newline at end of file diff --git a/api_generator/routes/base_imports b/api_generator/routes/base_imports deleted file mode 100644 index 03788c30..00000000 --- a/api_generator/routes/base_imports +++ /dev/null @@ -1,7 +0,0 @@ -from apps.api import blueprint -from apps.authentication.decorators import token_required - -from apps.api.forms import * -from apps.models import * - -api = Api(blueprint) diff --git a/api_generator/routes/base_route b/api_generator/routes/base_route deleted file mode 100644 index 8b69a576..00000000 --- a/api_generator/routes/base_route +++ /dev/null @@ -1,114 +0,0 @@ -@api.route('/{endpoint}/', methods=['POST', 'GET', 'DELETE', 'PUT']) -@api.route('/{endpoint}//', methods=['GET', 'DELETE', 'PUT']) -class {model_name}Route(Resource): - def get(self, model_id: int = None): - if model_id is None: - all_objects = {model_name}.query.all() - output = [{{'id': obj.id, **{form_name}(obj=obj).data}} for obj in all_objects] - else: - obj = {model_name}.query.get(model_id) - if obj is None: - return {{ - 'message': 'matching record not found', - 'success': False - }}, 404 - output = {{'id': obj.id, **{form_name}(obj=obj).data}} - return {{ - 'data': output, - 'success': True - }}, 200 - - @token_required - def post(self): - try: - body_of_req = request.form - if not body_of_req: - raise Exception() - except Exception: - if len(request.data) > 0: - body_of_req = json.loads(request.data) - else: - body_of_req = {{}} - form = {form_name}(MultiDict(body_of_req)) - if form.validate(): - try: - obj = {model_name}(**body_of_req) - {model_name}.query.session.add(obj) - {model_name}.query.session.commit() - except Exception as e: - return {{ - 'message': str(e), - 'success': False - }}, 400 - else: - return {{ - 'message': form.errors, - 'success': False - }}, 400 - return {{ - 'message': 'record saved!', - 'success': True - }}, 200 - - @token_required - def put(self, model_id: int): - try: - body_of_req = request.form - if not body_of_req: - raise Exception() - except Exception: - if len(request.data) > 0: - body_of_req = json.loads(request.data) - else: - body_of_req = {{}} - - to_edit_row = {model_name}.query.filter_by(id=model_id) - - if not to_edit_row: - return {{ - 'message': 'matching record not found', - 'success': False - }}, 404 - - obj = to_edit_row.first() - - if not obj: - return {{ - 'message': 'matching record not found', - 'success': False - }}, 404 - - form = {form_name}(MultiDict(body_of_req), obj=obj) - if not form.validate(): - return {{ - 'message': form.errors, - 'success': False - }}, 404 - - table_cols = [attr.name for attr in to_edit_row.__dict__['_raw_columns'][0].columns._all_columns] - - for col in table_cols: - value = body_of_req.get(col, None) - if value: - setattr(obj, col, value) - {model_name}.query.session.add(obj) - {model_name}.query.session.commit() - return {{ - 'message': 'record updated', - 'success': True - }} - - @token_required - def delete(self, model_id: int): - to_delete = {model_name}.query.filter_by(id=model_id) - if to_delete.count() == 0: - return {{ - 'message': 'matching record not found', - 'success': False - }}, 404 - to_delete.delete() - {model_name}.query.session.commit() - return {{ - 'message': 'record deleted!', - 'success': True - }}, 200 diff --git a/api_generator/routes/library_imports b/api_generator/routes/library_imports deleted file mode 100644 index 8def81af..00000000 --- a/api_generator/routes/library_imports +++ /dev/null @@ -1,5 +0,0 @@ -import json - -from flask import request -from flask_restx import Api, Resource -from werkzeug.datastructures import MultiDict diff --git a/api_generator/routes/routes_structure b/api_generator/routes/routes_structure deleted file mode 100644 index f88cfc29..00000000 --- a/api_generator/routes/routes_structure +++ /dev/null @@ -1,5 +0,0 @@ -{library_imports} - -{project_imports} - -{routes} diff --git a/apps/__init__.py b/apps/__init__.py index 4942b4b9..943563bc 100644 --- a/apps/__init__.py +++ b/apps/__init__.py @@ -3,46 +3,43 @@ Copyright (c) 2019 - present AppSeed.us """ +import os from flask import Flask from flask_login import LoginManager from flask_sqlalchemy import SQLAlchemy from importlib import import_module - db = SQLAlchemy() login_manager = LoginManager() - def register_extensions(app): db.init_app(app) login_manager.init_app(app) - def register_blueprints(app): - for module_name in ('authentication', 'home', 'api'): + for module_name in ('authentication', 'home', 'dyn_dt', 'charts',): module = import_module('apps.{}.routes'.format(module_name)) app.register_blueprint(module.blueprint) +from apps.authentication.oauth import github_blueprint, google_blueprint -def configure_database(app): +def create_app(config): - @app.before_first_request - def initialize_database(): - db.create_all() + # Contextual + static_prefix = '/static' + templates_dir = os.path.dirname(config.BASE_DIR) - @app.teardown_request - def shutdown_session(exception=None): - db.session.remove() + TEMPLATES_FOLDER = os.path.join(templates_dir,'templates') + STATIC_FOLDER = os.path.join(templates_dir,'static') -from apps.authentication.oauth import github_blueprint + print(' > TEMPLATES_FOLDER: ' + TEMPLATES_FOLDER) + print(' > STATIC_FOLDER: ' + STATIC_FOLDER) + + app = Flask(__name__, static_url_path=static_prefix, template_folder=TEMPLATES_FOLDER, static_folder=STATIC_FOLDER) -def create_app(config): - app = Flask(__name__) app.config.from_object(config) register_extensions(app) register_blueprints(app) - - app.register_blueprint(github_blueprint, url_prefix="/login") - - configure_database(app) + app.register_blueprint(github_blueprint, url_prefix="/login") + app.register_blueprint(google_blueprint, url_prefix="/login") return app diff --git a/apps/api/forms.py b/apps/api/forms.py deleted file mode 100644 index 608bb123..00000000 --- a/apps/api/forms.py +++ /dev/null @@ -1,14 +0,0 @@ -from wtforms import Form -from wtforms_alchemy import model_form_factory - - -from apps.models import * - - -ModelForm = model_form_factory(Form) - - -class BookForm(ModelForm): - class Meta: - model = Book - diff --git a/apps/api/routes.py b/apps/api/routes.py deleted file mode 100644 index d6f29037..00000000 --- a/apps/api/routes.py +++ /dev/null @@ -1,131 +0,0 @@ -import json - -from flask import request -from flask_restx import Api, Resource -from werkzeug.datastructures import MultiDict - - -from apps.api import blueprint -from apps.authentication.decorators import token_required - -from apps.api.forms import * -from apps.models import * - -api = Api(blueprint) - - -@api.route('/books/', methods=['POST', 'GET', 'DELETE', 'PUT']) -@api.route('/books//', methods=['GET', 'DELETE', 'PUT']) -class BookRoute(Resource): - def get(self, model_id: int = None): - if model_id is None: - all_objects = Book.query.all() - output = [{'id': obj.id, **BookForm(obj=obj).data} for obj in all_objects] - else: - obj = Book.query.get(model_id) - if obj is None: - return { - 'message': 'matching record not found', - 'success': False - }, 404 - output = {'id': obj.id, **BookForm(obj=obj).data} - return { - 'data': output, - 'success': True - }, 200 - - @token_required - def post(self): - try: - body_of_req = request.form - if not body_of_req: - raise Exception() - except Exception: - if len(request.data) > 0: - body_of_req = json.loads(request.data) - else: - body_of_req = {} - form = BookForm(MultiDict(body_of_req)) - if form.validate(): - try: - obj = Book(**body_of_req) - Book.query.session.add(obj) - Book.query.session.commit() - except Exception as e: - return { - 'message': str(e), - 'success': False - }, 400 - else: - return { - 'message': form.errors, - 'success': False - }, 400 - return { - 'message': 'record saved!', - 'success': True - }, 200 - - @token_required - def put(self, model_id: int): - try: - body_of_req = request.form - if not body_of_req: - raise Exception() - except Exception: - if len(request.data) > 0: - body_of_req = json.loads(request.data) - else: - body_of_req = {} - - to_edit_row = Book.query.filter_by(id=model_id) - - if not to_edit_row: - return { - 'message': 'matching record not found', - 'success': False - }, 404 - - obj = to_edit_row.first() - - if not obj: - return { - 'message': 'matching record not found', - 'success': False - }, 404 - - form = BookForm(MultiDict(body_of_req), obj=obj) - if not form.validate(): - return { - 'message': form.errors, - 'success': False - }, 404 - - table_cols = [attr.name for attr in to_edit_row.__dict__['_raw_columns'][0].columns._all_columns] - - for col in table_cols: - value = body_of_req.get(col, None) - if value: - setattr(obj, col, value) - Book.query.session.add(obj) - Book.query.session.commit() - return { - 'message': 'record updated', - 'success': True - } - - @token_required - def delete(self, model_id: int): - to_delete = Book.query.filter_by(id=model_id) - if to_delete.count() == 0: - return { - 'message': 'matching record not found', - 'success': False - }, 404 - to_delete.delete() - Book.query.session.commit() - return { - 'message': 'record deleted!', - 'success': True - }, 200 - diff --git a/apps/authentication/decorators.py b/apps/authentication/decorators.py deleted file mode 100644 index 9cf48592..00000000 --- a/apps/authentication/decorators.py +++ /dev/null @@ -1,48 +0,0 @@ -from datetime import datetime -from functools import wraps - -import jwt -from flask import request, current_app - -from apps.authentication.models import Users - - -def token_required(func): - @wraps(func) - def decorated(*args, **kwargs): - if 'Authorization' in request.headers: - token = request.headers['Authorization'] - else: - return { - 'message': 'Token is missing', - 'data': None, - 'success': False - }, 403 - try: - data = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=["HS256"]) - current_user = Users.query.filter_by(id=data['user_id']).first() - if current_user is None: - return { - 'message': 'Invalid token', - 'data': None, - 'success': False - }, 403 - now = int(datetime.utcnow().timestamp()) - init_date = data['init_date'] - - # if now - init_date > 24 * 3600: # expire token after 24 hours - # return { - # 'message': 'Expired token', - # 'data': None, - # 'success': False - # }, 403 - - except Exception as e: - return { - 'message': str(e), - 'data': None, - 'success': False - }, 500 - return func(*args, **kwargs) - - return decorated diff --git a/apps/authentication/models.py b/apps/authentication/models.py index ba8b974d..443672b7 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -5,7 +5,7 @@ from flask_login import UserMixin -from sqlalchemy.orm import relationship +from sqlalchemy.exc import SQLAlchemyError, IntegrityError from flask_dance.consumer.storage.sqla import OAuthConsumerMixin from apps import db, login_manager @@ -14,17 +14,18 @@ class Users(db.Model, UserMixin): - __tablename__ = 'Users' + __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), unique=True) email = db.Column(db.String(64), unique=True) password = db.Column(db.LargeBinary) + bio = db.Column(db.Text(), nullable=True) oauth_github = db.Column(db.String(100), nullable=True) + oauth_google = db.Column(db.String(100), nullable=True) - api_token = db.Column(db.String(100)) - api_token_ts = db.Column(db.Integer) + readonly_fields = ["id", "username", "email", "oauth_github", "oauth_google"] def __init__(self, **kwargs): for property, value in kwargs.items(): @@ -43,12 +44,44 @@ def __init__(self, **kwargs): def __repr__(self): return str(self.username) + @classmethod + def find_by_email(cls, email: str) -> "Users": + return cls.query.filter_by(email=email).first() + + @classmethod + def find_by_username(cls, username: str) -> "Users": + return cls.query.filter_by(username=username).first() + + @classmethod + def find_by_id(cls, _id: int) -> "Users": + return cls.query.filter_by(id=_id).first() + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise IntegrityError(error, 422) + + def delete_from_db(self) -> None: + try: + db.session.delete(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise IntegrityError(error, 422) + return @login_manager.user_loader def user_loader(id): return Users.query.filter_by(id=id).first() - @login_manager.request_loader def request_loader(request): username = request.form.get('username') @@ -56,6 +89,5 @@ def request_loader(request): return user if user else None class OAuth(OAuthConsumerMixin, db.Model): - user_id = db.Column(db.Integer, db.ForeignKey("Users.id", ondelete="cascade"), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="cascade"), nullable=False) user = db.relationship(Users) - \ No newline at end of file diff --git a/apps/authentication/oauth.py b/apps/authentication/oauth.py index fa331655..99432d87 100644 --- a/apps/authentication/oauth.py +++ b/apps/authentication/oauth.py @@ -8,8 +8,8 @@ from flask_login import current_user, login_user from flask_dance.consumer import oauth_authorized from flask_dance.contrib.github import github, make_github_blueprint +from flask_dance.contrib.google import google, make_google_blueprint from flask_dance.consumer.storage.sqla import SQLAlchemyStorage -from flask_dance.contrib.twitter import twitter, make_twitter_blueprint from sqlalchemy.orm.exc import NoResultFound from apps.config import Config from .models import Users, db, OAuth @@ -25,7 +25,8 @@ db.session, user=current_user, user_required=False, - ), + ), + ) @oauth_authorized.connect_via(github_blueprint) @@ -56,3 +57,53 @@ def github_logged_in(blueprint, token): login_user(user) + + + +# Google + +google_blueprint = make_google_blueprint( + client_id=Config.GOOGLE_ID, + client_secret=Config.GOOGLE_SECRET, + scope=[ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ], + storage=SQLAlchemyStorage( + OAuth, + db.session, + user=current_user, + user_required=False, + ), + +) + +@oauth_authorized.connect_via(google_blueprint) +def google_logged_in(blueprint, token): + info = google.get("/oauth2/v1/userinfo") + + if info.ok: + account_info = info.json() + username = account_info["given_name"] + email = account_info["email"] + + query = Users.query.filter_by(oauth_google=username) + try: + + user = query.one() + login_user(user) + + except NoResultFound: + # Save to db + user = Users() + user.username = '(google)' + username + user.oauth_google = username + user.email = email + + # Save current user + db.session.add(user) + db.session.commit() + + login_user(user) + diff --git a/apps/authentication/routes.py b/apps/authentication/routes.py index ce235d0a..3e0d8a8e 100644 --- a/apps/authentication/routes.py +++ b/apps/authentication/routes.py @@ -3,34 +3,22 @@ Copyright (c) 2019 - present AppSeed.us """ -import json -from datetime import datetime - -from flask_restx import Resource, Api - -import flask from flask import render_template, redirect, request, url_for from flask_login import ( current_user, login_user, logout_user ) - from flask_dance.contrib.github import github +from flask_dance.contrib.google import google from apps import db, login_manager from apps.authentication import blueprint from apps.authentication.forms import LoginForm, CreateAccountForm from apps.authentication.models import Users +from apps.config import Config -from apps.authentication.util import verify_pass, generate_token - -# Bind API -> Auth BP -api = Api(blueprint) - -@blueprint.route('/') -def route_default(): - return redirect(url_for('authentication_blueprint.login')) +from apps.authentication.util import verify_pass # Login & Registration @@ -43,36 +31,53 @@ def login_github(): res = github.get("/user") return redirect(url_for('home_blueprint.index')) + +@blueprint.route("/google") +def login_google(): + """ Google login """ + if not google.authorized: + return redirect(url_for("google.login")) + + res = google.get("/oauth2/v1/userinfo") + return redirect(url_for('home_blueprint.index')) + @blueprint.route('/login', methods=['GET', 'POST']) def login(): login_form = LoginForm(request.form) - - if flask.request.method == 'POST': + if 'login' in request.form: # read form data - username = request.form['username'] + user_id = request.form['username'] # we can have here username OR email password = request.form['password'] - #return 'Login: ' + username + ' / ' + password - # Locate user - user = Users.query.filter_by(username=username).first() + user = Users.find_by_username(user_id) + + # if user not found + if not user: + + user = Users.find_by_email(user_id) + + if not user: + return render_template( 'authentication/login.html', + msg='Unknown User or Email', + form=login_form) # Check the password - if user and verify_pass(password, user.password): + if verify_pass(password, user.password): + login_user(user) - return redirect(url_for('authentication_blueprint.route_default')) + return redirect(url_for('home_blueprint.index')) # Something (user or pass) is not ok - return render_template('accounts/login.html', + return render_template('authentication/login.html', msg='Wrong user or password', form=login_form) - if current_user.is_authenticated: - return redirect(url_for('home_blueprint.index')) - else: - return render_template('accounts/login.html', - form=login_form) + if not current_user.is_authenticated: + return render_template('authentication/login.html', + form=login_form) + return redirect(url_for('home_blueprint.index')) @blueprint.route('/register', methods=['GET', 'POST']) @@ -86,7 +91,7 @@ def register(): # Check usename exists user = Users.query.filter_by(username=username).first() if user: - return render_template('accounts/register.html', + return render_template('authentication/register.html', msg='Username already registered', success=False, form=create_account_form) @@ -94,7 +99,7 @@ def register(): # Check email exists user = Users.query.filter_by(email=email).first() if user: - return render_template('accounts/register.html', + return render_template('authentication/register.html', msg='Email already registered', success=False, form=create_account_form) @@ -107,86 +112,31 @@ def register(): # Delete user from session logout_user() - return render_template('accounts/register.html', + return render_template('authentication/register.html', msg='User created successfully.', success=True, form=create_account_form) else: - return render_template('accounts/register.html', form=create_account_form) - -@api.route('/login/jwt/', methods=['POST']) -class JWTLogin(Resource): - def post(self): - try: - data = request.form - - if not data: - data = request.json - - if not data: - return { - 'message': 'username or password is missing', - "data": None, - 'success': False - }, 400 - # validate input - user = Users.query.filter_by(username=data.get('username')).first() - if user and verify_pass(data.get('password'), user.password): - try: - - # Empty or null Token - if not user.api_token or user.api_token == '': - user.api_token = generate_token(user.id) - user.api_token_ts = int(datetime.utcnow().timestamp()) - db.session.commit() - - # token should expire after 24 hrs - return { - "message": "Successfully fetched auth token", - "success": True, - "data": user.api_token - } - except Exception as e: - return { - "error": "Something went wrong", - "success": False, - "message": str(e) - }, 500 - return { - 'message': 'username or password is wrong', - 'success': False - }, 403 - except Exception as e: - return { - "error": "Something went wrong", - "success": False, - "message": str(e) - }, 500 + return render_template('authentication/register.html', form=create_account_form) @blueprint.route('/logout') def logout(): logout_user() - return redirect(url_for('authentication_blueprint.login')) + return redirect(url_for('home_blueprint.index')) # Errors -@login_manager.unauthorized_handler -def unauthorized_handler(): - return render_template('home/page-403.html'), 403 - - -@blueprint.errorhandler(403) -def access_forbidden(error): - return render_template('home/page-403.html'), 403 +@blueprint.context_processor +def has_github(): + return {'has_github': bool(Config.GITHUB_ID) and bool(Config.GITHUB_SECRET)} +@blueprint.context_processor +def has_google(): + return {'has_google': bool(Config.GOOGLE_ID) and bool(Config.GOOGLE_SECRET)} -@blueprint.errorhandler(404) -def not_found_error(error): - return render_template('home/page-404.html'), 404 - -@blueprint.errorhandler(500) -def internal_error(error): - return render_template('home/page-500.html'), 500 +@login_manager.unauthorized_handler +def unauthorized_handler(): + return redirect('/login') diff --git a/apps/authentication/util.py b/apps/authentication/util.py index 44d7f420..588a4175 100644 --- a/apps/authentication/util.py +++ b/apps/authentication/util.py @@ -7,13 +7,8 @@ import hashlib import binascii -import jwt -from datetime import datetime -from flask import current_app, request - # Inspiration -> https://www.vitoshacademy.com/hashing-passwords-in-python/ - def hash_pass(password): """Hash a password for storing.""" @@ -36,15 +31,3 @@ def verify_pass(provided_password, stored_password): 100000) pwdhash = binascii.hexlify(pwdhash).decode('ascii') return pwdhash == stored_password - -# Used in API Generator -def generate_token(aUserId): - now = int(datetime.utcnow().timestamp()) - api_token = jwt.encode( - {"user_id": aUserId, - "init_date": now}, - current_app.config["SECRET_KEY"], - algorithm="HS256" - ) - - return api_token \ No newline at end of file diff --git a/apps/api/__init__.py b/apps/charts/__init__.py similarity index 76% rename from apps/api/__init__.py rename to apps/charts/__init__.py index bdfcb3bc..66711689 100644 --- a/apps/api/__init__.py +++ b/apps/charts/__init__.py @@ -6,7 +6,7 @@ from flask import Blueprint blueprint = Blueprint( - 'api_blueprint', + 'charts_blueprint', __name__, - url_prefix='/api' + url_prefix='' ) diff --git a/apps/charts/routes.py b/apps/charts/routes.py new file mode 100644 index 00000000..f9e74da5 --- /dev/null +++ b/apps/charts/routes.py @@ -0,0 +1,13 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from apps.charts import blueprint +from flask import render_template +from apps.models import Product + +@blueprint.route('/charts') +def charts(): + products = [{'name': product.name, 'price': product.price} for product in Product.get_list()] + return render_template('charts/index.html', segment='charts', products=products) \ No newline at end of file diff --git a/apps/config.py b/apps/config.py index 22a296ad..e4079bb2 100644 --- a/apps/config.py +++ b/apps/config.py @@ -4,31 +4,85 @@ """ import os +from pathlib import Path class Config(object): - basedir = os.path.abspath(os.path.dirname(__file__)) + BASE_DIR = Path(__file__).resolve().parent + + USERS_ROLES = { 'ADMIN' :1 , 'USER' : 2 } + USERS_STATUS = { 'ACTIVE' :1 , 'SUSPENDED' : 2 } + + # celery + CELERY_BROKER_URL = "redis://localhost:6379" + CELERY_RESULT_BACKEND = "redis://localhost:6379" + CELERY_HOSTMACHINE = "celery@app-generator" # Set up the App SECRET_KEY - # SECRET_KEY = config('SECRET_KEY' , default='S#perS3crEt_007') - SECRET_KEY = os.getenv('SECRET_KEY', 'S#perS3crEt_007') - - # This will create a file in FOLDER - SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'db.sqlite3') - SQLALCHEMY_TRACK_MODIFICATIONS = False + SECRET_KEY = os.getenv('SECRET_KEY', 'S3cret_999') - # Assets Management - ASSETS_ROOT = os.getenv('ASSETS_ROOT', '/static/assets') - + # Social AUTH context SOCIAL_AUTH_GITHUB = False - GITHUB_ID = os.getenv('GITHUB_ID') - GITHUB_SECRET = os.getenv('GITHUB_SECRET') + GITHUB_ID = os.getenv('GITHUB_ID' , None) + GITHUB_SECRET = os.getenv('GITHUB_SECRET', None) # Enable/Disable Github Social Login if GITHUB_ID and GITHUB_SECRET: - SOCIAL_AUTH_GITHUB = True - + SOCIAL_AUTH_GITHUB = True + + GOOGLE_ID = os.getenv('GOOGLE_ID' , None) + GOOGLE_SECRET = os.getenv('GOOGLE_SECRET', None) + + # Enable/Disable Google Social Login + if GOOGLE_ID and GOOGLE_SECRET: + SOCIAL_AUTH_GOOGLE = True + + SQLALCHEMY_TRACK_MODIFICATIONS = False + + DB_ENGINE = os.getenv('DB_ENGINE' , None) + DB_USERNAME = os.getenv('DB_USERNAME' , None) + DB_PASS = os.getenv('DB_PASS' , None) + DB_HOST = os.getenv('DB_HOST' , None) + DB_PORT = os.getenv('DB_PORT' , None) + DB_NAME = os.getenv('DB_NAME' , None) + + USE_SQLITE = True + + # try to set up a Relational DBMS + if DB_ENGINE and DB_NAME and DB_USERNAME: + + try: + + # Relational DBMS: PSQL, MySql + SQLALCHEMY_DATABASE_URI = '{}://{}:{}@{}:{}/{}'.format( + DB_ENGINE, + DB_USERNAME, + DB_PASS, + DB_HOST, + DB_PORT, + DB_NAME + ) + + USE_SQLITE = False + + except Exception as e: + + print('> Error: DBMS Exception: ' + str(e) ) + print('> Fallback to SQLite ') + + if USE_SQLITE: + + # This will create a file in FOLDER + SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(BASE_DIR, 'db.sqlite3') + + DYNAMIC_DATATB = { + "products": "apps.models.Product" + } + + CDN_DOMAIN = os.getenv('CDN_DOMAIN') + CDN_HTTPS = os.getenv('CDN_HTTPS', True) + class ProductionConfig(Config): DEBUG = False @@ -37,26 +91,11 @@ class ProductionConfig(Config): REMEMBER_COOKIE_HTTPONLY = True REMEMBER_COOKIE_DURATION = 3600 - # PostgreSQL database - SQLALCHEMY_DATABASE_URI = '{}://{}:{}@{}:{}/{}'.format( - os.getenv('DB_ENGINE' , 'mysql'), - os.getenv('DB_USERNAME' , 'appseed_db_usr'), - os.getenv('DB_PASS' , 'pass'), - os.getenv('DB_HOST' , 'localhost'), - os.getenv('DB_PORT' , 3306), - os.getenv('DB_NAME' , 'appseed_db') - ) - class DebugConfig(Config): DEBUG = True - # Load all possible configurations config_dict = { 'Production': ProductionConfig, 'Debug' : DebugConfig } - -API_GENERATOR = { - "books": "Book", -} diff --git a/apps/db.sqlite3 b/apps/db.sqlite3 new file mode 100644 index 00000000..9e33d6a7 Binary files /dev/null and b/apps/db.sqlite3 differ diff --git a/apps/dyn_dt/__init__.py b/apps/dyn_dt/__init__.py new file mode 100644 index 00000000..3b6926c1 --- /dev/null +++ b/apps/dyn_dt/__init__.py @@ -0,0 +1,12 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask import Blueprint + +blueprint = Blueprint( + 'table_blueprint', + __name__, + url_prefix='' +) diff --git a/apps/dyn_dt/routes.py b/apps/dyn_dt/routes.py new file mode 100644 index 00000000..de1651d7 --- /dev/null +++ b/apps/dyn_dt/routes.py @@ -0,0 +1,351 @@ +import json, csv, io +from flask_login import login_required +from apps.dyn_dt import blueprint +from flask import render_template, request, redirect, url_for, jsonify, make_response +from apps.dyn_dt.utils import get_model_field_names, get_model_fk_values, name_to_class, user_filter, exclude_auto_gen_fields +from apps import db, config +from apps.dyn_dt.utils import * +from sqlalchemy import and_ +from sqlalchemy import Integer, DateTime, String, Text +from datetime import datetime + +@blueprint.route('/dynamic-dt') +def dynamic_dt(): + context = { + 'routes': config.Config.DYNAMIC_DATATB.keys(), + 'segment': 'dynamic_dt', + 'parent': 'dashboard', + } + return render_template('dyn_dt/index.html', **context) + +@blueprint.route('/create_filter/', methods=["POST"]) +def create_filter(model_name): + model_name = model_name.lower() + if request.method == "POST": + keys = request.form.getlist('key') + values = request.form.getlist('value') + + for key, value in zip(keys, values): + filter_instance = ModelFilter.query.filter_by(parent=model_name, key=key).first() + if filter_instance: + filter_instance.value = value + else: + filter_instance = ModelFilter(parent=model_name, key=key, value=value) + db.session.add(filter_instance) + + db.session.commit() + return redirect(url_for('table_blueprint.model_dt', aPath=model_name)) + + +@blueprint.route('/create_page_items/', methods=["POST"]) +def create_page_items(model_name): + model_name = model_name.lower() + if request.method == 'POST': + items = request.form.get('items') + page_items = PageItems.query.filter_by(parent=model_name).first() + if page_items: + page_items.items_per_page = items + else: + page_items = PageItems(parent=model_name, items_per_page=items) + db.session.add(page_items) + db.session.commit() + return redirect(url_for('table_blueprint.model_dt', aPath=model_name)) + + +@blueprint.route('/create_hide_show_filter/', methods=["POST"]) +def create_hide_show_filter(model_name): + model_name = model_name.lower() + if request.method == "POST": + data_str = list(request.form.keys())[0] + data = json.loads(data_str) + + filter_instance = HideShowFilter.query.filter_by(parent=model_name, key=data.get('key')).first() + if filter_instance: + filter_instance.value = data.get('value') + else: + filter_instance = HideShowFilter(parent=model_name, key=data.get('key'), value=data.get('value')) + + db.session.add(filter_instance) + db.session.commit() + + return jsonify({'message': 'Model updated successfully'}) + + +@blueprint.route('/delete_filter//', methods=["GET"]) +def delete_filter(model_name, id): + model_name = model_name.lower() + filter_instance = ModelFilter.query.filter_by(id=id, parent=model_name).first() + if filter_instance: + db.session.delete(filter_instance) + db.session.commit() + return redirect(url_for('table_blueprint.model_dt', aPath=model_name)) + return jsonify({'error': 'Filter not found'}), 404 + + +@blueprint.route('/dynamic-dt/', methods=['GET', 'POST']) +def model_dt(aPath): + aModelName = None + aModelClass = None + + if aPath in config.Config.DYNAMIC_DATATB.keys(): + aModelName = config.Config.DYNAMIC_DATATB[aPath] + aModelClass = name_to_class(aModelName) + + if not aModelClass: + return f'ERR: Getting ModelClass for path: {aPath}', 404 + + # db_fields = [field.name for field in aModelClass.__table__.columns] + db_fields = [field.name for field in aModelClass.__table__.columns if not field.foreign_keys] + fk_fields = get_model_fk_values(aModelClass) + db_filters = [] + for f in db_fields: + if f not in fk_fields.keys(): + db_filters.append( f ) + + choices_dict = {} + for column in aModelClass.__table__.columns: + if isinstance(column.type, db.Enum): + choices_dict[column.name] = [(choice.name, choice.value) for choice in column.type.enum_class] + + field_names = [] + for field_name in db_fields: + field = HideShowFilter.query.filter_by(parent=aPath.lower(), key=field_name).first() + if field: + field_names.append(field) + else: + field = HideShowFilter(parent=aPath.lower(), key=field_name) + db.session.add(field) + db.session.commit() + + field_names.append(field) + + filter_string = [] + filter_instance = ModelFilter.query.filter_by(parent=aPath.lower()).all() + for filter_data in filter_instance: + if filter_data.key in db_fields: + filter_string.append(getattr(aModelClass, filter_data.key).like(f"%{filter_data.value}%")) + + order_by = request.args.get('order_by', 'id') + if order_by not in db_fields: + order_by = 'id' + + queryset = aModelClass.query.filter(and_(*filter_string)).order_by(order_by) + + # Pagination + page_items = PageItems.query.filter_by(parent=aPath.lower()).order_by(PageItems.id.desc()).first() + p_items = 25 + if page_items: + p_items = page_items.items_per_page + + page = request.args.get('page', 1, type=int) + queryset = user_filter(request, queryset, db_fields, fk_fields.keys()) + pagination = queryset.paginate(page=page, per_page=p_items, error_out=False) + items = pagination.items + + # Read-only and field types + read_only_fields = ('id', 'user_id', 'date_created', 'date_modified', ) + integer_fields = get_model_field_names(aModelClass, Integer) + date_time_fields = get_model_field_names(aModelClass, DateTime) + text_fields = get_model_field_names(aModelClass, Text) + email_fields = [] + + # Context + context = { + 'page_title': f'Dynamic DataTable - {aPath.lower().title()}', + 'link': aPath, + 'field_names': field_names, + 'db_field_names': db_fields, + 'db_filters': db_filters, + 'items': items, + 'pagination': pagination, + 'page_items': p_items, + 'filter_instance': filter_instance, + 'read_only_fields': read_only_fields, + 'integer_fields': integer_fields, + 'date_time_fields': date_time_fields, + 'email_fields': email_fields, + 'text_fields': text_fields, + 'fk_fields_keys': fk_fields.keys(), + 'fk_fields': fk_fields, + 'segment': 'dynamic_dt', + 'parent': 'dashboard', + 'choices_dict': choices_dict, + 'exclude_auto_gen_fields': exclude_auto_gen_fields(aModelClass) + } + return render_template('dyn_dt/model.html', **context) + + +@blueprint.route('/create/', methods=["POST"]) +@login_required +def create(aPath): + aModelClass = None + + if aPath in config.Config.DYNAMIC_DATATB: + aModelName = config.Config.DYNAMIC_DATATB[aPath] + aModelClass = name_to_class(aModelName) + + if not aModelClass: + return ' > ERR: Getting ModelClass for path: ' + aPath + + if request.method == 'POST': + data = {} + fk_fields = get_model_fk_values(aModelClass) + + for attribute, value in request.form.items(): + if attribute in fk_fields.keys(): + table_name = None + for product in fk_fields[attribute]: + table_name = product.__class__.__tablename__ + if table_name: + model_name = config.Config.DYNAMIC_DATATB[table_name] + value = name_to_class(model_name).query.filter_by(id=value).first() + + data[attribute] = value if value else '' + + new_item = aModelClass(**data) + db.session.add(new_item) + db.session.commit() + + return redirect(request.referrer) + + +@blueprint.route('/delete//', methods=["GET"]) +@login_required +def delete(aPath, id): + aModelClass = None + + if aPath in config.Config.DYNAMIC_DATATB: + aModelName = config.Config.DYNAMIC_DATATB[aPath] + aModelClass = name_to_class(aModelName) + + if not aModelClass: + return ' > ERR: Getting ModelClass for path: ' + aPath + + item = aModelClass.query.get(id) + if item: + db.session.delete(item) + db.session.commit() + + return redirect(request.referrer) + + +@blueprint.route('/update//', methods=["POST"]) +@login_required +def update(aPath, id): + aModelClass = None + + if aPath in config.Config.DYNAMIC_DATATB: + aModelName = config.Config.DYNAMIC_DATATB[aPath] + aModelClass = name_to_class(aModelName) + + if not aModelClass: + return ' > ERR: Getting ModelClass for path: ' + aPath + + item = aModelClass.query.get(id) + if not item: + return 'Item not found', 404 + + fk_fields = get_model_fk_values(aModelClass) + + if request.method == 'POST': + for attribute, value in request.form.items(): + if hasattr(item, attribute) and getattr(item, attribute, value) is not None: + if attribute in fk_fields.keys(): + table_name = None + for product in fk_fields[attribute]: + table_name = product.__class__.__tablename__ + if table_name: + model_name = config.Config.DYNAMIC_DATATB[table_name] + value = name_to_class(model_name).query.filter_by(id=value).first() + + setattr(item, attribute, value) + + db.session.commit() + + return redirect(request.referrer) + + +@blueprint.route('/export/', methods=['GET']) +def export_csv(aPath): + aModelName = None + aModelClass = None + + if aPath in config.Config.DYNAMIC_DATATB: + aModelName = config.Config.DYNAMIC_DATATB[aPath] + aModelClass = name_to_class(aModelName) + + if not aModelClass: + return ' > ERR: Getting ModelClass for path: ' + aPath, 400 + + db_field_names = [column.name for column in aModelClass.__table__.columns] + fk_fields = get_model_fk_values(aModelClass) + + fields = [] + show_fields = HideShowFilter.query.filter_by(value=False, parent=aPath.lower()).all() + for field in show_fields: + if field.key in db_field_names: + fields.append(field.key) + else: + print(f"Field {field.key} does not exist in {aModelClass} model.") + + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(fields) + + # Filtering + filter_string = {} + filter_instance = ModelFilter.query.filter_by(parent=aPath.lower()).all() + for filter_data in filter_instance: + filter_string[f'{filter_data.key}__icontains'] = filter_data.value + + # Ordering + order_by = request.args.get('order_by', 'id') + query = aModelClass.query.filter_by(**filter_string).order_by(order_by) + items = user_filter(request, query, db_field_names, fk_fields) + + # Write rows to CSV + for item in items: + row_data = [] + for field in fields: + try: + row_data.append(getattr(item, field)) + except AttributeError: + row_data.append('') + writer.writerow(row_data) + + # Prepare response with CSV content + response = make_response(output.getvalue()) + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = f'attachment; filename="{aPath.lower()}.csv"' + + return response + + +# Template filter + +@blueprint.app_template_filter('getattribute') +def getattribute(value, arg): + try: + attr_value = getattr(value, arg) + + if isinstance(attr_value, datetime): + return attr_value.strftime("%Y-%m-%d %H:%M:%S") + + return attr_value + except AttributeError: + return '' + + +@blueprint.app_template_filter('getenumattribute') +def getenumattribute(value, arg): + try: + attr_value = getattr(value, arg) + return attr_value.value + except AttributeError: + return '' + + +@blueprint.app_template_filter('get') +def get(dict_data, key): + return dict_data.get(key, []) diff --git a/apps/dyn_dt/utils.py b/apps/dyn_dt/utils.py new file mode 100644 index 00000000..f7bd1dac --- /dev/null +++ b/apps/dyn_dt/utils.py @@ -0,0 +1,93 @@ +import importlib +from sqlalchemy import or_ +from sqlalchemy import DateTime, func +from apps import db + +class PageItems(db.Model): + __tablename__ = 'page_items' + id = db.Column(db.Integer, primary_key=True) + parent = db.Column(db.String(255), nullable=True) + items_per_page = db.Column(db.Integer, default=25) + + +class HideShowFilter(db.Model): + __tablename__ = 'hide_show_filter' + id = db.Column(db.Integer, primary_key=True) + parent = db.Column(db.String(255), nullable=True) + key = db.Column(db.String(255), nullable=False) + value = db.Column(db.Boolean, default=False) + +class ModelFilter(db.Model): + __tablename__ = 'model_filter' + id = db.Column(db.Integer, primary_key=True) + parent = db.Column(db.String(255), nullable=True) + key = db.Column(db.String(255), nullable=False) + value = db.Column(db.String(255), nullable=False) + + +def get_model_fk_values(aModelClass): + fk_values = {} + + current_table_name = aModelClass.__tablename__ + + for relationship in aModelClass.__mapper__.relationships: + if relationship.direction.name == 'MANYTOONE': + related_model = relationship.mapper.class_ + foreign_key_column = list(relationship.local_columns)[0] + referenced_table_name = list(foreign_key_column.foreign_keys)[0].column.table.name + + if referenced_table_name != current_table_name: + field_name = relationship.key + related_instances = related_model.query.all() + fk_values[field_name] = related_instances + + return fk_values + + +def get_model_field_names(model, field_type): + """Returns a list of field names based on the given field type in SQLAlchemy.""" + return [ + column.name for column in model.__table__.columns + if isinstance(column.type, field_type) + ] + + +def name_to_class(name: str): + try: + module_name = '.'.join(name.split('.')[:-1]) + class_name = name.split('.')[-1] + + module = importlib.import_module(module_name) + return getattr(module, class_name) + except Exception as e: + print(f"Error importing {name}: {e}") + return None + + +def user_filter(request, query, fields, fk_fields=[]): + value = request.args.get('search') + + if value: + dynamic_filter = [] + + for field in fields: + if field not in fk_fields: + dynamic_filter.append(getattr(query.column_descriptions[0]['entity'], field).ilike(f"%{value}%")) + + query = query.filter(or_(*dynamic_filter)) + + return query + + +def exclude_auto_gen_fields(aModelClass): + exclude_fields = [ + field.name for field in aModelClass.__table__.columns + if isinstance(field.type, DateTime) and ( + field.default is not None or + field.server_default is not None or + field.onupdate is not None or + isinstance(field.default, func) or + isinstance(field.onupdate, func) + ) + ] + return exclude_fields diff --git a/apps/exceptions/exception.py b/apps/exceptions/exception.py new file mode 100644 index 00000000..803279d5 --- /dev/null +++ b/apps/exceptions/exception.py @@ -0,0 +1,15 @@ +class InvalidUsage(Exception): + status_code = 400 + + def __init__(self, message, status_code=None, payload=None): + Exception.__init__(self) + self.message = message + if status_code is not None: + self.status_code = status_code + self.payload = payload + + def to_dict(self): + rv = dict(self.payload or ()) + rv['message'] = self.message + + return rv \ No newline at end of file diff --git a/apps/helpers.py b/apps/helpers.py new file mode 100644 index 00000000..a76d2dcd --- /dev/null +++ b/apps/helpers.py @@ -0,0 +1,176 @@ +import os +import re +import uuid +import re +from colorama import Fore, Style +from apps.authentication.models import Users +from apps.config import Config +from marshmallow import ValidationError +from apps.messages import Messages +from functools import wraps +from flask import request +from uuid import uuid4 +import datetime, time +message = Messages.message + +Currency = Config.CURRENCY +PAYMENT_TYPE = Config.PAYMENT_TYPE +STATE = Config.STATE + + +regex = re.compile(r'([A-Za-z0-9]+[.-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(\.[A-Z|a-z]{2,})+') + +def get_ts(): + return int(time.time()) + +def password_validate(password): + """ password validate """ + msg = '' + while True: + if len(password) < 6: + msg = "Make sure your password is at lest 6 letters" + return msg + elif re.search('[0-9]',password) is None: + msg = "Make sure your password has a number in it" + return msg + elif re.search('[A-Z]',password) is None: + msg = "Make sure your password has a capital letter in it" + return msg + else: + msg = True + break + + return True + +def emailValidate(email): + """ validate email """ + if re.fullmatch(regex, email): + return True + else: + return False + +# santise file name +def sanitise_fille_name(value): + """ remove special char """ + return value.strip().lower().replace(' ', '_').replace('(', '').replace(')', '').replace(',', '').replace('=','_').replace('-', '_').replace('#', '') + +def createFolder(folder_name): + """ create folder for save csv """ + if not os.path.exists(f'{folder_name}'): + os.makedirs(f'{folder_name}') + + return folder_name + + +def uniqueFileName(file_name): + """ for Unique file name""" + file_uuid = uuid.uuid4() + IMAGE_NAME = f'{file_uuid}-{file_name}' + return IMAGE_NAME + +def serverImageUrl(file_name): + """ for Unique file name""" + url = f'{FTP_IMAGE_URL}{file_name}' + return url + +def errorColor(error): + """ for terminal input error color """ + print(Fore.RED + f'{error}') + print(Style.RESET_ALL) + return True + +def splitUrlGetFilename(url): + """ image url split and get file name """ + return url.split('/')[-1] + +def validateCurrency(currency): + """ check currency """ + # if check currency validate or not + if currency not in list(Currency.keys()): + raise ValidationError( + f"{message['invalid_currency']}, expected {','.join(Currency.keys())}", 422) + +def validatePaymentMethod(payment): + """ check valid payment methods """ + # if check PAYMENT_TYPE validate or not + if payment not in list(PAYMENT_TYPE.keys()): + raise ValidationError( + f"{message['invalid_payment_method']}, expected {expectedValue(PAYMENT_TYPE)}", 422) + + else: + value = 0 + if payment == "cc": + value = 1 + elif payment == "paypal": + value = 2 + else: + value = 3 + + return value + +def validateState(state): + """ check valid state methods """ + # if check state validate or not + if state not in list(STATE.keys()): + raise ValidationError( + f"{message['invalid_state']}, expected {expectedValue(STATE)}", 422) + + else: + value = 0 + if state == "completed": + value = 1 + elif state == "pending": + value = 2 + else: + value = 3 + + return value + + +def expectedValue(data): + """ key get values """ + values = [] + for k,v in data.items(): + values.append(f'{v}.({k})') + + return ",".join(values) + + +def createAccessToken(): + """ create access token w""" + rand_token = uuid4() + + return f"{str(rand_token)}" + + +# token validate +def token_required(f): + """ check token """ + @wraps(f) + def decorated(*args, **kwargs): + token = None + if "Authorization" in request.headers: + token = request.headers["Authorization"] + if not token: + return { + "message": "Authentication Token is missing!", + "error": "Unauthorized" + }, 401 + try: + current_user = Users.find_by_api_token(token) + if current_user is None: + return { + "message": "Invalid Authentication token!", + "error": "Unauthorized" + }, 401 + # if not current_user["active"]: + # abort(403) + except Exception as e: + return { + "message": "Something went wrong", + "error": str(e) + }, 500 + + return f(current_user, **kwargs) + + return decorated diff --git a/apps/home/routes.py b/apps/home/routes.py index bf77fe5a..06e310db 100644 --- a/apps/home/routes.py +++ b/apps/home/routes.py @@ -2,44 +2,106 @@ """ Copyright (c) 2019 - present AppSeed.us """ - +import wtforms from apps.home import blueprint -from flask import render_template, request -from flask_login import login_required +from flask import render_template, request, redirect, url_for from jinja2 import TemplateNotFound +from flask_login import login_required, current_user +from apps import db +from apps.authentication.models import Users +from flask_wtf import FlaskForm -from apps.config import API_GENERATOR - +@blueprint.route('/') @blueprint.route('/index') -@login_required def index(): + return render_template('pages/index.html', segment='dashboard', parent="dashboard") + +@blueprint.route('/billing') +def billing(): + return render_template('pages/billing.html', segment='billing') + +@blueprint.route('/rtl') +def rtl(): + return render_template('pages/rtl.html', segment='rtl') + +@blueprint.route('/tables') +def tables(): + return render_template('pages/tables.html', segment='tables') + +@blueprint.route('/virtual_reality') +def virtual_reality(): + return render_template('pages/virtual-reality.html', segment='virtual_reality') + + +def getField(column): + if isinstance(column.type, db.Text): + return wtforms.TextAreaField(column.name.title()) + if isinstance(column.type, db.String): + return wtforms.StringField(column.name.title()) + if isinstance(column.type, db.Boolean): + return wtforms.BooleanField(column.name.title()) + if isinstance(column.type, db.Integer): + return wtforms.IntegerField(column.name.title()) + if isinstance(column.type, db.Float): + return wtforms.DecimalField(column.name.title()) + if isinstance(column.type, db.LargeBinary): + return wtforms.HiddenField(column.name.title()) + return wtforms.StringField(column.name.title()) + + +@blueprint.route('/profile', methods=['GET', 'POST']) +@login_required +def profile(): - return render_template('home/index.html', segment='index') + class ProfileForm(FlaskForm): + pass + readonly_fields = Users.readonly_fields + full_width_fields = {"bio"} -@blueprint.route('/