From 5abfdaaf0ad613d3a9fb5b90ece0de0d4fdf8c5e Mon Sep 17 00:00:00 2001 From: Fer-Bar Date: Mon, 10 Oct 2022 00:09:48 -0400 Subject: [PATCH] Added a url shortener app for everyone can use it. --- Url Shortener/.flaskenv | 1 + Url Shortener/README.md | 80 +++++++++++++++ Url Shortener/config.py | 33 +++++++ Url Shortener/core/.env | 4 + Url Shortener/core/__init__.py | 13 +++ Url Shortener/core/models.py | 19 ++++ Url Shortener/core/routes.py | 62 ++++++++++++ Url Shortener/core/shorty.db | Bin 0 -> 28672 bytes Url Shortener/core/templates/base.html | 27 ++++++ Url Shortener/core/templates/index.html | 36 +++++++ Url Shortener/main.py | 4 + Url Shortener/migrations/README | 1 + Url Shortener/migrations/alembic.ini | 50 ++++++++++ Url Shortener/migrations/env.py | 91 ++++++++++++++++++ Url Shortener/migrations/script.py.mako | 24 +++++ ...723e0_change_the_model_name_to_singular.py | 44 +++++++++ Url Shortener/requirements.txt | Bin 0 -> 988 bytes Url Shortener/tests/conftest.py | 9 ++ Url Shortener/tests/test_models.py | 12 +++ 19 files changed, 510 insertions(+) create mode 100644 Url Shortener/.flaskenv create mode 100644 Url Shortener/README.md create mode 100644 Url Shortener/config.py create mode 100644 Url Shortener/core/.env create mode 100644 Url Shortener/core/__init__.py create mode 100644 Url Shortener/core/models.py create mode 100644 Url Shortener/core/routes.py create mode 100644 Url Shortener/core/shorty.db create mode 100644 Url Shortener/core/templates/base.html create mode 100644 Url Shortener/core/templates/index.html create mode 100644 Url Shortener/main.py create mode 100644 Url Shortener/migrations/README create mode 100644 Url Shortener/migrations/alembic.ini create mode 100644 Url Shortener/migrations/env.py create mode 100644 Url Shortener/migrations/script.py.mako create mode 100644 Url Shortener/migrations/versions/4b81e66723e0_change_the_model_name_to_singular.py create mode 100644 Url Shortener/requirements.txt create mode 100644 Url Shortener/tests/conftest.py create mode 100644 Url Shortener/tests/test_models.py diff --git a/Url Shortener/.flaskenv b/Url Shortener/.flaskenv new file mode 100644 index 00000000..97d8d129 --- /dev/null +++ b/Url Shortener/.flaskenv @@ -0,0 +1 @@ +FLASK_APP=main.py \ No newline at end of file diff --git a/Url Shortener/README.md b/Url Shortener/README.md new file mode 100644 index 00000000..4aaf5a94 --- /dev/null +++ b/Url Shortener/README.md @@ -0,0 +1,80 @@ +# 📎UrlShortener +> This is an UrlShortener built with Flask and Postgresql. +> +> + + +# 👨‍💻Installation +## 📄Pre-Requirements +- Python Installed (Recommended version 3.8 or above) +- Pip Package Manager (pip) +## ⚙️How to use it? +1. Download this repository with git clone or by clicking the download as archive on this page + + ``` + git clone https://github.com/Fer-Bar/UrlShortener.git + ``` + Go to the project directory. + ``` + cd url_shortener + ``` + +2. Create a virtual environment: + ### 🪟Windows: + + ``` + py -m venv venv + ``` + Once created you can activate it. + ``` + venv\Scripts\activate.bat + ``` + ### 🐧Unix or MacOS: + + ``` + pip install virtualenv + virtualenv venv + ``` + Once created you can activate it. + ``` + source venv/bin/activate + ``` +3. Install dependencies with `pip install -r requirements.txt`. Make sure everything is installed properly with `pip freeze`. + +4. The last step is run the [main.py](main.py) file, if you want you can change the host and the port. For example: + ``` + app.run(host='localhost', port=9000, debug=True) + ``` +## 🧪 Tests +- To run tests, run the following command: + + ``` + python -m pytest -v + ``` +## 🔃Migrations +- To run the migrations, just run the following commands: + + This command adds a `migrations` directory in the root of your project. This is a directory where all the migration scripts are going to be stored. + + ``` + flask db init + ``` + The next step in the process is to create an initial migration, using the `migrate` command: + + ``` + flask db migrate -m "Initial migration." + ``` + Then you can apply the migration to the database: + + ``` + flask db migrate -m "Initial migration." + ``` +## 😎 Author + +👤 **Fernando Barrientos** + + +* Website: [fer-bar.github.io](https://fer-bar.github.io/Portfolio/) +* Github: [Fer-Bar](https://github.com/Fer-Bar) + diff --git a/Url Shortener/config.py b/Url Shortener/config.py new file mode 100644 index 00000000..914d5f13 --- /dev/null +++ b/Url Shortener/config.py @@ -0,0 +1,33 @@ +from decouple import config + + +DATABASE_URI = config("DATABASE_URL") +if DATABASE_URI.startswith("postgres://"): + DATABASE_URI = DATABASE_URI.replace("postgres://", "postgresql://", 1) + + +class Config(object): + DEBUG = False + TESTING = False + CSRF_ENABLED = True + SECRET_KEY = config('SECRET_KEY', default='guess-me') + SQLALCHEMY_DATABASE_URI = DATABASE_URI + SQLALCHEMY_TRACK_MODIFICATIONS = False + + +class ProductionConfig(Config): + DEBUG = False + + +class StagingConfig(Config): + DEVELOPMENT = True + DEBUG = True + + +class DevelopmentConfig(Config): + DEVELOPMENT = True + DEBUG = True + + +class TestingConfig(Config): + TESTING = True \ No newline at end of file diff --git a/Url Shortener/core/.env b/Url Shortener/core/.env new file mode 100644 index 00000000..cd81d052 --- /dev/null +++ b/Url Shortener/core/.env @@ -0,0 +1,4 @@ +SECRET_KEY=b48d8c4c124c654036d0250be7ff9dbb +DATABASE_URL=sqlite:///shorty.db +APP_SETTINGS=config.DevelopmentConfig +FLASK_APP=core \ No newline at end of file diff --git a/Url Shortener/core/__init__.py b/Url Shortener/core/__init__.py new file mode 100644 index 00000000..77d250c7 --- /dev/null +++ b/Url Shortener/core/__init__.py @@ -0,0 +1,13 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from decouple import config + + +app = Flask(__name__) +app.config.from_object(config("APP_SETTINGS")) + +db = SQLAlchemy(app) +migrate = Migrate(app, db) + +from core import routes \ No newline at end of file diff --git a/Url Shortener/core/models.py b/Url Shortener/core/models.py new file mode 100644 index 00000000..b653a0ac --- /dev/null +++ b/Url Shortener/core/models.py @@ -0,0 +1,19 @@ +from core import db +from datetime import datetime + + +class ShortUrl(db.Model): + """ + Class that represents a shorturl of our application + The following attributes of a shorturl are stored in this table: + * original_url - url who user give us + * short_id - short_id that serves to redirect to the original_url + * created_at - creation date + """ + id = db.Column(db.Integer, primary_key=True) + original_url = db.Column(db.String(500), nullable=False) + short_id = db.Column(db.String(20), nullable=False, unique=True) + created_at = db.Column(db.DateTime(), default=datetime.now(), nullable=False) + + def __repr__(self): + return f'ShortUrl: {self.short_id}' \ No newline at end of file diff --git a/Url Shortener/core/routes.py b/Url Shortener/core/routes.py new file mode 100644 index 00000000..cb4ac781 --- /dev/null +++ b/Url Shortener/core/routes.py @@ -0,0 +1,62 @@ +import string +import validators + +from datetime import datetime +from core.models import ShortUrl +from core import app, db +from random import choice +from flask import render_template, request, flash, redirect, url_for + + +def generate_short_id(num_of_chars: int) -> str: + """Function to generate short_id of specified number of characters""" + return ''.join(choice(string.ascii_letters+string.digits) for _ in range(num_of_chars)) + + +@app.route('/', methods=['GET', 'POST']) +def index(): + """ + The main view receives POST and GET requests + """ + if request.method == 'POST': + url = request.form['url'] + short_id = request.form['custom_id'] + + if short_id and ShortUrl.query.filter_by(short_id=short_id).first() is not None: + flash('Please enter different custom id!') + return redirect(url_for('index')) + + if not validators.url(url): + flash('Enter a valid url.') + return redirect(url_for('index')) + + if not url: + flash('The URL is required!') + return redirect(url_for('index')) + + if not short_id: + short_id = generate_short_id(8) + + new_link = ShortUrl(original_url=url, + short_id=short_id, + created_at=datetime.now()) + db.session.add(new_link) + db.session.commit() + short_url = request.host_url + short_id + + return render_template('index.html', short_url=short_url) + + return render_template('index.html') + +@app.route('/') +def redirect_url(short_id: str): + """ + This view redirects to the original_url address \n + Only receives GET requests + """ + link = ShortUrl.query.filter_by(short_id=short_id).first() + if link: + return redirect(link.original_url) + else: + flash('Invalid URL') + return redirect(url_for('index')) \ No newline at end of file diff --git a/Url Shortener/core/shorty.db b/Url Shortener/core/shorty.db new file mode 100644 index 0000000000000000000000000000000000000000..b46b59070c4cc2a48ca0a1f2ccb11b9edb9c1b7d GIT binary patch literal 28672 zcmeI)PjA~~90zbaf15O!8PXQ@P}Lop)T*=dJa(La5)z^Fwy-p9vy`z5k()S;*CZ~s z+ptMPDigc_-T)Vlyn)@|!j%&e2M)V&0&&=3M;@nhXhIpN(hgK#Cr|1petys6=i?^_ zKdyamyJmT&*tMO3;fX5u7RU44d!op3+*Ow1EJaxgu{6h0gr#8NgO%6(y~@d(PtxfZ zTqO0J6Vk%7)bsTB{_8a{*aQIxKmY;|fB*y_009U<;9LU7A4lVvr6vC9yl3q8O}A$| zUhB~5PgY{<4ZYIT#b)LGnl4Vh7FUu9t0PwHP5rjs5bK*wvA$EQ-4K%r+p+enq0#rN z5kIUn*6&mrD;goIQ>9L7VpRksl$io;$22^%(_*JxH`xv}s~h@M1zU~kMy2sUysJMD zSFFye-}Fwsy1k=|D?#O}$yFMQW%MPUvxXh>Q}>fTJDY16hn_vow^PtKTnKdjJ`S8wc4#C({U}E z9r3AbXo_fNqwn-8tTj9wOa_6fOplWF&HBA&qr!%nI8#IGV81O+je%43vE%-3D3;k? z`=<`hRz_#O2(urPe?HHpS>lC1IQGT@0SG_<0uX=z1Rwwb2tWV=5P-l15xB&MuIF}( z)Xe7#ifWQ1>le=dFUY|~Hy{832tWV=5P$##AOHafKmY=-1cdPQ#Od>Y|N1}sr++LE zfB*y_009U<00Izz00bZa0SH`t0smh9)A#mY{2rr+5P$##AOHafKmY;|fB*y_0D%iG zFdtq_Gz%Nb!^6G%{`LP<%Hf1(f^+G&OM~?9>F?5Crfcc9Q$MG^VH>bO00Izz00bZa z0SG_<0uVU207*r;cNQww`gYss_iWd@Miqs!7f}?66eOyMidt54WunMhj^+w^Wu8SW zo{o5EZ}*zJ)}5IcwX9KD)u={kF&X8uiyYU)}vd~ZIV)1Ey)V& zV1X3lXGg9(TaM-4|6nGvP}WK^%@I?7_Lv!S24<30vdnkGANrH}L^rfCXk~~{Fd1#?z zNbK2%()e+K=FoJcL)RMaN!`BT?q??ll8l>I*&Y;RqEd}0pGExUFTc^|4P(0de)~DO zsOCscJr0Ha9awm?JJ9JG-7|KA%9OB?uIZsDSN&^bc8z`W1Y2&~1K-_rz3i6L*j7pH z=x=?YMVV4U@+F=B57kg-my|q5ovubHl@*PRc1_D^{{8=nV(oREbufM3t#39BjUGUjAtXle(7LNA>?2G I4`v4b1V5KYNdN!< literal 0 HcmV?d00001 diff --git a/Url Shortener/core/templates/base.html b/Url Shortener/core/templates/base.html new file mode 100644 index 00000000..b6002ac6 --- /dev/null +++ b/Url Shortener/core/templates/base.html @@ -0,0 +1,27 @@ + + + + + + + + + + + {% block title %} {% endblock %} + + +
+ {% for message in get_flashed_messages() %} +
{{ message }}
+ {% endfor %} + {% block content %} {% endblock %} +
+ + + + + + + + \ No newline at end of file diff --git a/Url Shortener/core/templates/index.html b/Url Shortener/core/templates/index.html new file mode 100644 index 00000000..c07768f4 --- /dev/null +++ b/Url Shortener/core/templates/index.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} + +{% block content %} +

{% block title %} Welcome to Linkcito {% endblock %}

+
+
+
+
+
+ + +
+
+ + +
+ +
+ +
+
+ + {% if short_url %} +
+
This is your short URL:
+ + {% endif %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/Url Shortener/main.py b/Url Shortener/main.py new file mode 100644 index 00000000..1ccd9e8a --- /dev/null +++ b/Url Shortener/main.py @@ -0,0 +1,4 @@ +from core import app + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/Url Shortener/migrations/README b/Url Shortener/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/Url Shortener/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/Url Shortener/migrations/alembic.ini b/Url Shortener/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/Url Shortener/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/Url Shortener/migrations/env.py b/Url Shortener/migrations/env.py new file mode 100644 index 00000000..68feded2 --- /dev/null +++ b/Url Shortener/migrations/env.py @@ -0,0 +1,91 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/Url Shortener/migrations/script.py.mako b/Url Shortener/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/Url Shortener/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/Url Shortener/migrations/versions/4b81e66723e0_change_the_model_name_to_singular.py b/Url Shortener/migrations/versions/4b81e66723e0_change_the_model_name_to_singular.py new file mode 100644 index 00000000..8f6df513 --- /dev/null +++ b/Url Shortener/migrations/versions/4b81e66723e0_change_the_model_name_to_singular.py @@ -0,0 +1,44 @@ +"""Change the model name to singular + +Revision ID: 4b81e66723e0 +Revises: +Create Date: 2022-07-01 23:13:12.373286 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4b81e66723e0' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('short_url', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('original_url', sa.String(length=500), nullable=False), + sa.Column('short_id', sa.String(length=20), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('short_id') + ) + op.drop_table('short_urls') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('short_urls', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('original_url', sa.VARCHAR(length=500), nullable=False), + sa.Column('short_id', sa.VARCHAR(length=20), nullable=False), + sa.Column('created_at', sa.DATETIME(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('short_id') + ) + op.drop_table('short_url') + # ### end Alembic commands ### diff --git a/Url Shortener/requirements.txt b/Url Shortener/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..dff900d344aee24d114e8745302be91040cfaf9e GIT binary patch literal 988 zcmZ`&O;5s55S+7#KLrCtR1O}zcrbD>9z54l3j&X(d>H)k>dfvduaKDLrAcSr%UfVze}gIe40J82wWe~LxU0G26BPnJ$;?iAbacY+3*K!V)Q?voq#?FL&F*tn z;u*DhdL1Pt5ylU24{Rurz8iyh1sQd3-18&b>h=C4HB> z_AEJTf=Y92(-z0K)Ms%^B+bIn8M_bYF-zYp^{eSA5vkpDloEGsx&eFR$uTou&+fl% tk+*hv80+&tO8&>RWz&GE@xm8Mi{sB%3jjI3v literal 0 HcmV?d00001 diff --git a/Url Shortener/tests/conftest.py b/Url Shortener/tests/conftest.py new file mode 100644 index 00000000..3be74d63 --- /dev/null +++ b/Url Shortener/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest +from core.models import ShortUrl + + +@pytest.fixture(scope='module') +def new_shorturl(): + shorturl = ShortUrl(original_url='https://fakepython.com/pytest/', + short_id='1rb09tr3') + return shorturl diff --git a/Url Shortener/tests/test_models.py b/Url Shortener/tests/test_models.py new file mode 100644 index 00000000..a5d576e4 --- /dev/null +++ b/Url Shortener/tests/test_models.py @@ -0,0 +1,12 @@ +from hashlib import new + + +def test_new_shorturl_with_fixture(new_shorturl): + """ + GIVEN a ShortUrl model \n + WHEN a new ShortUrl is created \n + THEN check the original_url and short_id fields are defined correctly + """ + assert new_shorturl.original_url == 'https://fakepython.com/pytest/' + assert new_shorturl.short_id == '1rb09tr3' + assert new_shorturl.__repr__() == 'ShortUrl: 1rb09tr3'