commit 7d68b93238f2c7cd95a9cef16579f888d2d9e33b Author: tomeros Date: Wed Mar 24 15:11:34 2021 +0200 Initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3368348 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3 + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "run.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cfc4126 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Tomer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..5b9ff7a --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn --bind 0.0.0.0:$PORT run:app \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6aa5cb1 Binary files /dev/null and b/README.md differ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..d67733e --- /dev/null +++ b/app/app.py @@ -0,0 +1,5 @@ +from app.setup.setup import create_app + +CONFIG_FILE = 'settings.py' + +app = create_app(config_file=CONFIG_FILE) diff --git a/app/client/static/CSS/styles.css b/app/client/static/CSS/styles.css new file mode 100644 index 0000000..094f443 --- /dev/null +++ b/app/client/static/CSS/styles.css @@ -0,0 +1,222 @@ +#heading-p { + margin-top: 1.5%; +} + +.btn:active { + box-shadow: none; +} + +.title-bullet { + width: 100%; + height: 0; + border-top: 1px solid #ddd; + display: block; + margin: 30px 0; +} + +.title-bullet span { + width: 70px; + height: 6px; + display: table; + margin-top: -3px; + background: #4b97ed; +} + + +span { + font-style: inherit; + font-weight: inherit; +} + +.form-control-borderless { + border: none; +} + +.form-control-borderless:hover, .form-control-borderless:active, .form-control-borderless:focus { + border: none; + outline: none; + box-shadow: none; +} + +.btn:not(:disabled):not(.disabled) { + background: rgb(29, 184, 84); + border-style: none; + box-shadow: none; +} + +.btn:not(:disabled):not(.disabled) { + background: rgb(29, 184, 84); + border-style: none; + box-shadow: none; +} + +.btn:hover { + background-color: white; +} + + +#long-link { + margin-top: 2%; +} + +#credits { + margin-top: 10%; +} + + +.dh-highlight-text { + text-align: center; + padding: 30px; +} + +/**/ +/*=================*/ + +.fa-check:hover { + opacity: 1 !important; +} + +.fa-check { + padding: 0 !important; +} + +.bullet { + list-style-type: none; +} + +.res-home-btn { + padding-top: 1.25rem; +} + +.fa { + padding: 20px; + font-size: 30px; + width: 50px; + text-align: center; + text-decoration: none; + margin: 5px 2px; +} + +.fa:hover { + opacity: 0.7; + text-decoration: none; + +} + +.fa-facebook { + background: #3B5998; + color: white; +} + + +.fa-twitter { + background: #55ACEE; + color: white; +} + + +.fa-linkedin { + background: #007bb5; + color: white; +} + +.fa-reddit { + background: #ff5700; + color: white; +} + + +.fa { + box-sizing: unset; +} + + +/*--------------*/ + +.alert-box { + padding: 15px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 4px; +} + +.success { + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; + display: none; + margin-top: 3%; +} + +.c { + width: auto; + padding: 0; + font-size: 17px !important; +} + + +body { + background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #2dd591); + /*background: linear-gradient(-45deg, #06D6A0, #EF476F, #06D6A0, #FFD166);*/ + background-size: 400% 400%; + animation: gradient 50s ease infinite; +} + +@keyframes gradient { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +.credits-card { + display: inline-block; + padding: 8px; + opacity: .9; +} + +.alert-warning { + display: none; + margin-top: 3%; +} + +.card { + border-radius: 17px; +} + +.dh-highlight-text { + margin-top: 40px; +} + +pre { + margin-bottom: 0; +} + +.row { + margin-bottom: 30px; +} + +.code-block { + background: #212529; + padding: 10px; + border-radius: 13px; + border: none; + box-shadow: 4px 4px 12px 0 #212529; +} + +pre { + color: whitesmoke; +} + +#invalid-verification { + display: block !important; +} + +#api-card { + margin-top: 0 !important; +} \ No newline at end of file diff --git a/app/client/static/JS/main.js b/app/client/static/JS/main.js new file mode 100644 index 0000000..70435e5 --- /dev/null +++ b/app/client/static/JS/main.js @@ -0,0 +1,58 @@ +function copyToClipboard() { + let copyText = document.getElementById("copy-able"); + copyText.select(); + copyText.setSelectionRange(0, 99999); /*For mobile devices*/ + document.execCommand("copy"); +} + + +$("#copy-btn").click(function () { + $("div.success").fadeIn(300).delay(1500).fadeOut(900); +}); + +function checkUrl(event) { + let value = document.getElementById('url-input').value; + if (value.length < 5) { + $("div.alert-warning").fadeIn(300).delay(1500).fadeOut(900); + event.preventDefault(); + return false; + } +} + + +function checkEmail(event) { + const re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + let email = document.getElementById('email_input').value; + let isEmailValid = re.test(String(email).toLowerCase()); + + if (email.length < 1) { + $("div#cant-be-empty").fadeIn(300).delay(1500).fadeOut(900); + event.preventDefault(); + return false; + + } else if (isEmailValid === false) { + $("div#invalid-email").fadeIn(300).delay(1500).fadeOut(900); + event.preventDefault(); + return false; + } +} + +function callGetToken() { + let xhttp = new XMLHttpRequest(); + let hostUrl = document.getElementById('host-url').getAttribute('value'); + xhttp.onreadystatechange = function () { + if (this.readyState === 4 && this.status === 200) { + document.getElementById("token").innerHTML = this.responseText; + } + }; + xhttp.open("GET", `${hostUrl}/api/get_token`, true); + xhttp.send(); +} + +$('#verification').on('keyup', function () { + var foo = $(this).val().split(" ").join(""); + if (foo.length > 0) { + foo = foo.match(new RegExp('.{1,3}', 'g')).join(" "); + } + $(this).val(foo); +}); \ No newline at end of file diff --git a/app/client/static/favicon.ico b/app/client/static/favicon.ico new file mode 100644 index 0000000..ce33935 Binary files /dev/null and b/app/client/static/favicon.ico differ diff --git a/app/client/templates/404.html b/app/client/templates/404.html new file mode 100644 index 0000000..ac9fff7 --- /dev/null +++ b/app/client/templates/404.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
+
+
+

🌴 404

+

We could not find this page 🙁

+

+ + + + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/api_doc.html b/app/client/templates/api_doc.html new file mode 100644 index 0000000..ddc0a1d --- /dev/null +++ b/app/client/templates/api_doc.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
+
+
+ +

The ShortMe API

+

ShortMe's API will allow you to access ShortMe's URL shortening capabilities over + API.

+

What you need to get started

+
    +
  • Access token
  • +
+ +

Shorten your first link!

+
    +
  1. Generate an access token.
  2. +
  3. You'll use the POST method to the /api/shorten endpoint. Append your + access token as a header in your request.
    + Here's an example: + <Authorization: Bearer {token}>

    + Example call:
    + POST http://shortme.biz/api/shorten?url=http://www.longurl.com

    + Example Response: +
    +
    {
    'short_url': shortme.biz/f3Jds,
    'original_url': 'http://www.longurl.com',
    'success':True
    }
    +
    +
  4. +
+
+ + +

Get total URL clicks

+

You can use ShortMe's API to track the total number of clicks your short URL received

+
    +
  1. You'll use the GET method to the /api/total_clicks endpoint. Access token + is not required.
    +
    + Example call:
    + GET http://shortme.biz/api/total_clicks?url=shortme.biz/f3Jds

    + Example Response: +
    +
    {
    'total': 2,
    'short_url': 'shortme.biz/f3Jds',
    'original_url': 'http://www.longurl.com',
    'success': True
    }
    +
    +
  2. +
+ + + {# #} + {# #} + {# #} + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/base.html b/app/client/templates/base.html new file mode 100644 index 0000000..95ebb28 --- /dev/null +++ b/app/client/templates/base.html @@ -0,0 +1,44 @@ + + + + + + URL shortner + + + + + + + + + + +
+
+
+

ShortMe

+

ShortMe is a free tool to shorten URLs. Create a short & memorable URL in seconds.

+
+
+
+ {% block content %} + {% endblock %} + + + + + + + + + + + \ No newline at end of file diff --git a/app/client/templates/error.html b/app/client/templates/error.html new file mode 100644 index 0000000..9c86c0f --- /dev/null +++ b/app/client/templates/error.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block content %} + + +
+
+
+
+
+

Yikes! We could not shorten this URL 🙁

+

 The URL you entered is not valid.

+
    +
  • Make sure + the website is online
  • +
  • Check if + the URL is valid
  • + +
  •  The + URL may have + been blocked
  • +
  •  The + url may have + been reported
  • +
+ + + + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/get_token.html b/app/client/templates/get_token.html new file mode 100644 index 0000000..d3276ee --- /dev/null +++ b/app/client/templates/get_token.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block content %} + + +
+
+
+
+
+ +

+

Get your free ShortMe's API token

+

Please enter your email below to get a verification code

+
+
+ + + +
+
+
+ +
+

*If you alredy verified your email you will get the API token

+
Please enter a valid email address +
+
This feild cannot be empty
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/index.html b/app/client/templates/index.html new file mode 100644 index 0000000..e28c23e --- /dev/null +++ b/app/client/templates/index.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} {% block content %} + + +
+
+
+
+
+
+ + + + + + +
+
This field cannot be empty
+
+
+
+
+
+ + +
+
+

+
Check out ShortMe's + API
+

+
+
+ +
+
+
with + by Tomer
+ +
Check out my GitHub
+
+
+ + +{% endblock %} diff --git a/app/client/templates/total-clicks.html b/app/client/templates/total-clicks.html new file mode 100644 index 0000000..21c0d4e --- /dev/null +++ b/app/client/templates/total-clicks.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block content %} + +
+
+
+
+
+

Total URL Clicks

+

Your URL has been clicked {{ total_clicks }} times so far.

+ + + + + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/verify.html b/app/client/templates/verify.html new file mode 100644 index 0000000..15becbf --- /dev/null +++ b/app/client/templates/verify.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block content %} + + +
+
+
+
+
+ +

+

Please check your inbox

+

We've sent a verification code to your email. Please enter it below and click + verify.

+ +
+
+ + + + +
+
+
+ + + {% if is_verified == 'False' %} +
+ Your email already exist in our database but it's not activated.
Please check + your + inbox for the verification + code. +
+ {% endif %} + + {% if is_code_valid %} +
Wrong code, plesee try + again +
+ {% endif %} +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/your_api_token.html b/app/client/templates/your_api_token.html new file mode 100644 index 0000000..2fc65e8 --- /dev/null +++ b/app/client/templates/your_api_token.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
+
+
+ +

+

Your API token

+ +
+ + + +
+
Token copied to clipboard
+
This feild cannot be empty
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/your_short_url.html b/app/client/templates/your_short_url.html new file mode 100644 index 0000000..7ecbc1f --- /dev/null +++ b/app/client/templates/your_short_url.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
+
+
+

Your shortened URL

+

Copy the shortened link to share it.

+ +
+
+ + + + + + +
+
+ +
URL copied to clipboard
+ +
+ + + + +
+ +
+

Share your URL

+
+ + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/server/__init__.py b/app/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/server/api/__init__.py b/app/server/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/server/api/api.py b/app/server/api/api.py new file mode 100644 index 0000000..ebc625f --- /dev/null +++ b/app/server/api/api.py @@ -0,0 +1,112 @@ +# ------- standard library imports ------- +import requests + +# ------- 3rd party imports ------- +from flask_restful import Resource, reqparse + +# ------- local imports ------- +from app.server.db.extensions import db +from app.server.api.api_auth import auth +from app.server.db.models import Url, AuthToken + +shorten_parser = reqparse.RequestParser() +total_clicks_parser = reqparse.RequestParser() + +shorten_parser.add_argument('url', type=str, help='URL parameter is missing', required=True) +total_clicks_parser.add_argument('url', type=str, help='Short URL is missing', required=True) + + +class Shorten(Resource): + """ + Return a short URL from a given URL + URL: /api/shorten + METHOD: POST + PARAMS: url: long URL + HEADERS: Authorization: (Bearer ) + RETURN: dictionary with the short URL + """ + + decorators = [auth.login_required] + + @staticmethod + def post(): + args = shorten_parser.parse_args() + url = args['url'] + original_url = url if url.startswith('http') else ('http://' + url) + + try: + res = requests.get(original_url) + + if res.status_code == 200: + url = Url(original_url=original_url) + + db.session.add(url) + db.session.commit() + + return dict( + short_url=url.short_url, + original_url=url.original_url, + success=True + ), 200 + + else: + """in case of page_not_found response from the URL given""" + return dict( + success=False, + message='could not shorten this URL (page_not_found)' + ), 404 + + except (requests.exceptions.MissingSchema, requests.exceptions.ConnectionError, requests.exceptions.InvalidURL): + return dict( + success=False, + message='could not shorten this URL (page_not_found)' + ), 404 + + +class TotalClicks(Resource): + """ + return the total clicks of a short URL + URL: /api/total_clicks + METHOD: GET + PARAMS: short url + RETURN: dictionary with the total short url visits. + """ + + @staticmethod + def get(): + args = total_clicks_parser.parse_args() + url = args['url'].split('/')[-1] + + try: + url = Url.query.filter_by(short_url=url).first() + + return dict( + total=url.visits, + short_url=url.short_url, + original_url=url.original_url, + success=True + ), 200 + + except AttributeError: + return dict( + success=False, + message='could not find the URL (page_not_found)' + ), 404 + + +class GetToken(Resource): + """ + Return a unique API authorization token. Used only internaly by the web app. + URL: /api/get_token + METHOD: GET + RETURN: unique API authorization token + AuthToken: Required + """ + decorators = [auth.login_required] + + @staticmethod + def get(): + token = AuthToken() + db.session.add(token) + db.session.commit() + return str(token) diff --git a/app/server/api/api_auth.py b/app/server/api/api_auth.py new file mode 100644 index 0000000..b964e06 --- /dev/null +++ b/app/server/api/api_auth.py @@ -0,0 +1,12 @@ +from flask_httpauth import HTTPTokenAuth + +from app.server.db.models import AuthToken + +auth = HTTPTokenAuth(scheme='Bearer') + + +@auth.verify_token +def verify_token(token): + """Used to verify user's Token""" + return AuthToken.query.filter_by(auth_token=token).first() + diff --git a/app/server/db/__init__.py b/app/server/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/server/db/extensions.py b/app/server/db/extensions.py new file mode 100644 index 0000000..a51e6c6 --- /dev/null +++ b/app/server/db/extensions.py @@ -0,0 +1,4 @@ +# ------- 3rd party imports ------- +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/app/server/db/models.py b/app/server/db/models.py new file mode 100644 index 0000000..d468ec0 --- /dev/null +++ b/app/server/db/models.py @@ -0,0 +1,87 @@ +# ------- standard library imports ------- +import random +import string +import secrets +from random import choices +from datetime import datetime + +# ------- local imports ------- +from sqlalchemy import ForeignKey +from .extensions import db + + +class Url(db.Model): + id = db.Column(db.Integer, primary_key=True) + original_url = db.Column(db.String(512)) + short_url = db.Column(db.String(5), unique=True) + visits = db.Column(db.Integer, default=0) + date_created = db.Column(db.DateTime, default=datetime.now) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.short_url = self.generate_short_url() + + def generate_short_url(self): + characters = string.digits + string.ascii_letters + short_url = ''.join(choices(characters, k=5)) + url = self.query.filter_by(short_url=short_url).first() + + if url: + return self.generate_short_url() + + return short_url + + def __repr__(self): + return f'{self.original_url}, {self.visits}' + + +class AuthToken(db.Model): + __tablename__ = 'auth_token' + id = db.Column(db.Integer, primary_key=True) + auth_token = db.Column(db.String(16)) + date_created = db.Column(db.DateTime, default=datetime.now) + email_id = db.Column(db.Integer, ForeignKey('email.id')) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.auth_token = self.generate_auth_token() if self.auth_token is None else self.auth_token + + @staticmethod + def generate_auth_token(): + generated_token = secrets.token_hex(16) + return generated_token + + def __repr__(self): + return f'{self.auth_token}' + + +class Email(db.Model): + __tablename__ = 'email' + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(512), unique=True) + date_created = db.Column(db.DateTime, default=datetime.now) + is_verified = db.Column(db.Boolean, default=False) + verification_code = db.relationship('VerificationCode', backref='email') + auth_token = db.relationship('AuthToken', backref='email') + + def __repr__(self): + return f'{self.email}, {self.verification_code}, {self.auth_token}' + + +class VerificationCode(db.Model): + __tablename__ = 'verification_code' + id = db.Column(db.Integer, primary_key=True) + email_id = db.Column(db.Integer, ForeignKey('email.id')) + verification_code = db.Column(db.String, unique=True) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.verification_code = self.generate_verification_code() + + @staticmethod + def generate_verification_code(): + verification_code = ' '.join([str(random.randint(0, 999)).zfill(3) for _ in range(2)]) + return verification_code + + def __repr__(self): + return f'{self.verification_code}' diff --git a/app/server/routes/api_doc.py b/app/server/routes/api_doc.py new file mode 100644 index 0000000..bfaae21 --- /dev/null +++ b/app/server/routes/api_doc.py @@ -0,0 +1,9 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, render_template + +api_doc_blueprint = Blueprint('api_doc_blueprint', __name__, template_folder='templates') + + +@api_doc_blueprint.route('/api_doc') +def api_doc(): + return render_template('api_doc.html') diff --git a/app/server/routes/error.py b/app/server/routes/error.py new file mode 100644 index 0000000..b4f420b --- /dev/null +++ b/app/server/routes/error.py @@ -0,0 +1,9 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, render_template + +error_blueprint = Blueprint('error_blueprint', __name__, template_folder='templates') + + +@error_blueprint.route('/error') +def error(): + return render_template('error.html') diff --git a/app/server/routes/get_token.py b/app/server/routes/get_token.py new file mode 100644 index 0000000..46dc32f --- /dev/null +++ b/app/server/routes/get_token.py @@ -0,0 +1,9 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, render_template + +get_token_blueprint = Blueprint('get_token_blueprint', __name__, template_folder='templates') + + +@get_token_blueprint.route('/get_token') +def get_token(): + return render_template('get_token.html') diff --git a/app/server/routes/index.py b/app/server/routes/index.py new file mode 100644 index 0000000..ca612f6 --- /dev/null +++ b/app/server/routes/index.py @@ -0,0 +1,9 @@ +# ------- 3rd party imports ------- +from flask import render_template, Blueprint + +index_blueprint = Blueprint('index_blueprint', __name__, template_folder='templates') + + +@index_blueprint.route("/") +def index(): + return render_template('index.html') diff --git a/app/server/routes/internal/__init__.py b/app/server/routes/internal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/server/routes/internal/favicon.py b/app/server/routes/internal/favicon.py new file mode 100644 index 0000000..186fcee --- /dev/null +++ b/app/server/routes/internal/favicon.py @@ -0,0 +1,13 @@ +# ------- standard library imports ------- +import os + +# ------- 3rd party imports ------- +from flask import Blueprint, send_from_directory + +app_blueprint = Blueprint('app_blueprint', __name__) + + +@app_blueprint.route('/favicon.ico') +def favicon(): + return send_from_directory(os.path.join(app_blueprint.root_path, 'static'), + 'favicon.ico', mimetype='image/vnd.microsoft.icon') diff --git a/app/server/routes/internal/redirect_to_url.py b/app/server/routes/internal/redirect_to_url.py new file mode 100644 index 0000000..24fc93a --- /dev/null +++ b/app/server/routes/internal/redirect_to_url.py @@ -0,0 +1,24 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, redirect, url_for + +# ------- local imports ------- +from app.server.db.models import Url +from app.server.db.extensions import db + +redirect_to_url_blueprint = Blueprint('redirect_to_url_blueprint', __name__) + + +@redirect_to_url_blueprint.route('/') +def redirect_to_url(short_url): + """ + This function will query the database with the short_url and will + redirect to the original url if it exist in the database. + """ + url = Url.query.filter_by(short_url=short_url).first() + + if url: + url.visits = url.visits + 1 + db.session.commit() + return redirect(url.original_url) + + return redirect(url_for('page_not_found_blueprint.page_not_found')) diff --git a/app/server/routes/internal/send_verification_code.py b/app/server/routes/internal/send_verification_code.py new file mode 100644 index 0000000..4e4af4a --- /dev/null +++ b/app/server/routes/internal/send_verification_code.py @@ -0,0 +1,50 @@ +# ------- standard library imports ------- +import re + +# ------- 3rd party imports ------- +from flask_mail import Mail, Message +from flask import Blueprint, request, redirect, url_for, session + +# ------- local imports ------- +from app import app +from app.server.db.extensions import db +from app.server.db.models import VerificationCode, Email + +send_otp_blueprint = Blueprint('send_otp_blueprint', __name__) + + +@send_otp_blueprint.route('/email_validation', methods=['POST']) +def email_validation(): + input_email = request.form['email'] + pattern = r'[^@]+@[^@]+\.[^@]+' + is_email_valid = re.match(pattern, input_email) + + if is_email_valid: + email = Email.query.filter_by(email=input_email).first() + + if email and email.is_verified: + auth_token = Email.query.filter_by(email=input_email).first().auth_token + return redirect(url_for('your_api_token_blueprint.your_api_token', auth_token=auth_token)) + + elif email and not email.is_verified: + return redirect(url_for('verify_code_blueprint.enter_verification_code', is_verified=False)) + + else: + if not email: + session['user_email'] = input_email + + email = Email(email=input_email) + verification_code = VerificationCode(email=email) + db.session.add(verification_code, email) + db.session.commit() + + # print('user added to db') + # print('verification_code:') + # print(verification_code) + # print('*' * 33) + + mail = Mail(app.app) + msg = Message('ShortMe Verification Code', sender='shortme.biz@gmail.com', recipients=[email.email]) + msg.body = str(f'Hi!\nThis is your verification code: {verification_code.verification_code}') + mail.send(msg) + return redirect(url_for('verify_code_blueprint.enter_verification_code')) diff --git a/app/server/routes/internal/shorten_url.py b/app/server/routes/internal/shorten_url.py new file mode 100644 index 0000000..99320ac --- /dev/null +++ b/app/server/routes/internal/shorten_url.py @@ -0,0 +1,40 @@ +# ------- standard library imports ------- +import json +import requests + +# ------- 3rd party imports ------- +import flask +from flask import Blueprint, request, redirect, url_for + +# ------- local imports ------- +from app import app + +shorten_url_blueprint = Blueprint('shorten_url_blueprint', __name__, template_folder='templates') + + +@shorten_url_blueprint.route('/shorten', methods=['POST']) +def shorten_url(): + base_url = flask.url_for("index_blueprint.index", _external=True) + original_url = request.form['original_url'] + shorten_endpoint = base_url + 'api/shorten' + + params = { + 'url': original_url + } + + headers = { + 'Authorization': f'Bearer {app.app.secret_key}' + } + + response = requests.post(shorten_endpoint, headers=headers, params=params) + + if response.status_code == 200: + response = json.loads(response.text) + short_url = response['short_url'] + original_url = response['original_url'] + + return redirect(url_for('your_short_url_blueprint.your_short_url', + short_url=short_url, + original_url=original_url)) + else: + return redirect(url_for('error_blueprint.error')) diff --git a/app/server/routes/page_not_found.py b/app/server/routes/page_not_found.py new file mode 100644 index 0000000..a3c270e --- /dev/null +++ b/app/server/routes/page_not_found.py @@ -0,0 +1,9 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, render_template + +page_not_found_blueprint = Blueprint('page_not_found_blueprint', __name__, template_folder='templates') + + +@page_not_found_blueprint.route('/page_not_found') +def page_not_found(): + return render_template('404.html') diff --git a/app/server/routes/total_clicks.py b/app/server/routes/total_clicks.py new file mode 100644 index 0000000..66ca62d --- /dev/null +++ b/app/server/routes/total_clicks.py @@ -0,0 +1,24 @@ +# ------- standard library imports ------- +import json +import requests + +# ------- 3rd party imports ------- +import flask +from flask import Blueprint, render_template, request + +total_clicks_blueprint = Blueprint('total_clicks_blueprint', __name__, template_folder='templates') + + +@total_clicks_blueprint.route('/total_clicks') +def total_clicks(): + short_url = request.args['short_url'] + base_url = flask.url_for("index_blueprint.index", _external=True) + total_clicks_endpoint = base_url + 'api/total_clicks' + + params = { + 'url': short_url + } + + response = requests.get(total_clicks_endpoint, params=params) + total_url_clicks = json.loads(response.text)['total'] + return render_template('total-clicks.html', total_clicks=total_url_clicks) diff --git a/app/server/routes/verify_code.py b/app/server/routes/verify_code.py new file mode 100644 index 0000000..14843da --- /dev/null +++ b/app/server/routes/verify_code.py @@ -0,0 +1,32 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, render_template, request, redirect, url_for, session + +# ------- local imports ------- +from app.server.db.extensions import db +from app.server.db.models import VerificationCode, AuthToken, Email + +verify_code_blueprint = Blueprint('verify_code_blueprint', __name__, template_folder='templates') + + +@verify_code_blueprint.route('/verify', methods=['GET', 'POST']) +def enter_verification_code(): + is_verified = request.args.get('is_verified') + is_code_valid = request.args.get('is_code_valid') + return render_template('verify.html', is_code_valid=is_code_valid, is_verified=is_verified) + + +@verify_code_blueprint.route('/validate_code', methods=['POST']) +def validate_code(): + input_code = request.form['verification'] + code = VerificationCode.query.filter_by(verification_code=input_code).first() + + if code: + email = Email.query.filter_by(email=session['user_email']).first() + email.is_verified = True + auth_token = AuthToken(email=email) + db.session.add(auth_token) + db.session.commit() + return redirect(url_for('your_api_token_blueprint.your_api_token', auth_token=auth_token)) + + else: + return redirect(url_for('verify_code_blueprint.enter_verification_code', is_code_valid=False)) diff --git a/app/server/routes/your_api_token.py b/app/server/routes/your_api_token.py new file mode 100644 index 0000000..8be00a1 --- /dev/null +++ b/app/server/routes/your_api_token.py @@ -0,0 +1,15 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, render_template, request, url_for + +your_api_token_blueprint = Blueprint('your_api_token_blueprint', __name__, template_folder='templates') + + +@your_api_token_blueprint.route('/your_api_token') +def your_api_token(): + auth_token = request.args.get("auth_token") + + if auth_token: + return render_template('your_api_token.html', auth_token=auth_token) + + else: + return render_template(url_for('page_not_found_blueprint.page_not_found')) diff --git a/app/server/routes/your_short_url.py b/app/server/routes/your_short_url.py new file mode 100644 index 0000000..97ac9d0 --- /dev/null +++ b/app/server/routes/your_short_url.py @@ -0,0 +1,19 @@ +# ------- 3rd party imports ------- +import flask +from flask import Blueprint, render_template, request + +your_short_url_blueprint = Blueprint('your_short_url_blueprint', __name__, template_folder='templates') + + +@your_short_url_blueprint.route('/your_short_url') +def your_short_url(): + original_url = request.args['original_url'] + short_url = request.args['short_url'] + base_url = flask.url_for("index_blueprint.index", _external=True) + full_short_url = f'{base_url}{short_url}' + full_short_url = full_short_url.replace('http://www.', '') + + return render_template('your_short_url.html', + original_url=original_url, + short_url=short_url, + full_short_url=full_short_url) diff --git a/app/setup/settings.py b/app/setup/settings.py new file mode 100644 index 0000000..277aa09 --- /dev/null +++ b/app/setup/settings.py @@ -0,0 +1,26 @@ +import os + +SECRET_KEY = os.environ.get('SECRET_KEY') +SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') +SQLALCHEMY_TRACK_MODIFICATIONS = os.environ.get('SQLALCHEMY_TRACK_MODIFICATIONS') +ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME') +ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD') + +MAIL_SERVER = os.environ.get('MAIL_SERVER') +MAIL_PORT = int(os.environ.get('MAIL_PORT')) +MAIL_USERNAME = os.environ.get('MAIL_USERNAME') +MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') +MAIL_USE_TLS = False +MAIL_USE_SSL = True + +""" +:::::::: .env file content :::::::: +SQLALCHEMY_DATABASE_URI=sqlite:///db.sqlite3 +SQLALCHEMY_TRACK_MODIFICATIONS=False +SECRET_KEY=randomKey + +MAIL_SERVER=smtp.gmail.com +MAIL_PORT=465 +MAIL_USERNAME=email@email.com +MAIL_PASSWORD=password +""" diff --git a/app/setup/setup.py b/app/setup/setup.py new file mode 100644 index 0000000..5c79885 --- /dev/null +++ b/app/setup/setup.py @@ -0,0 +1,69 @@ +# ------- standard library imports ------- +import os + +# ------- 3rd party imports ------- +from flask import Flask +from flask_restful import Api +from dotenv import load_dotenv + +# ------- local imports ------- +from app.server.db.extensions import db +from app.server.db.models import AuthToken +from app.server.routes.index import index_blueprint +from app.server.routes.internal.redirect_to_url import redirect_to_url_blueprint +from app.server.routes.internal.favicon import app_blueprint +from app.server.routes.internal.send_verification_code import send_otp_blueprint +from app.server.routes.internal.shorten_url import shorten_url_blueprint +from app.server.routes.your_short_url import your_short_url_blueprint +from app.server.routes.total_clicks import total_clicks_blueprint +from app.server.routes.error import error_blueprint +from app.server.routes.page_not_found import page_not_found_blueprint +from app.server.routes.api_doc import api_doc_blueprint +from app.server.routes.get_token import get_token_blueprint +from app.server.routes.your_api_token import your_api_token_blueprint +from app.server.routes.verify_code import verify_code_blueprint + +from app.server.api.api import Shorten, TotalClicks, GetToken + + +def create_app(config_file): + """ + Creating and returning the app + """ + app_path = os.path.dirname(os.path.abspath(__file__)) + project_folder = os.path.expanduser(app_path) + load_dotenv(os.path.join(project_folder, '.env')) + + app = Flask(__name__, template_folder='../client/templates', static_folder='../client/static') + api = Api(app) + app.config.from_pyfile(config_file) + + db.init_app(app) + + with app.app_context(): + db.drop_all() + db.create_all() + + app_auth_token = app.secret_key + auth_token = AuthToken(auth_token=app_auth_token) + db.session.add(auth_token) + db.session.commit() + + api.add_resource(Shorten, '/api/shorten') + api.add_resource(GetToken, '/api/get_token') + api.add_resource(TotalClicks, '/api/total_clicks') + + app.register_blueprint(index_blueprint) + app.register_blueprint(page_not_found_blueprint) + app.register_blueprint(redirect_to_url_blueprint) + app.register_blueprint(your_short_url_blueprint) + app.register_blueprint(total_clicks_blueprint) + app.register_blueprint(error_blueprint) + app.register_blueprint(app_blueprint) + app.register_blueprint(api_doc_blueprint) + app.register_blueprint(get_token_blueprint) + app.register_blueprint(send_otp_blueprint) + app.register_blueprint(verify_code_blueprint) + app.register_blueprint(your_api_token_blueprint) + app.register_blueprint(shorten_url_blueprint) + return app diff --git a/app/tests/api_testing/__init__.py b/app/tests/api_testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/api_testing/api_helper.py b/app/tests/api_testing/api_helper.py new file mode 100644 index 0000000..7ea6996 --- /dev/null +++ b/app/tests/api_testing/api_helper.py @@ -0,0 +1,18 @@ +import json +from app.app import app as a + + +class ApiHelper: + HEADERS = { + 'Authorization': f'Bearer {a.secret_key}' + } + + def get_auth_token(self, app): + r = app.test_client().get( + '/api/get_token', headers=self.HEADERS + ) + + print(r.get_data(as_text=True)) + + key = json.loads(r.get_data(as_text=True)) + return key diff --git a/app/tests/api_testing/settings.py b/app/tests/api_testing/settings.py new file mode 100644 index 0000000..277aa09 --- /dev/null +++ b/app/tests/api_testing/settings.py @@ -0,0 +1,26 @@ +import os + +SECRET_KEY = os.environ.get('SECRET_KEY') +SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') +SQLALCHEMY_TRACK_MODIFICATIONS = os.environ.get('SQLALCHEMY_TRACK_MODIFICATIONS') +ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME') +ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD') + +MAIL_SERVER = os.environ.get('MAIL_SERVER') +MAIL_PORT = int(os.environ.get('MAIL_PORT')) +MAIL_USERNAME = os.environ.get('MAIL_USERNAME') +MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') +MAIL_USE_TLS = False +MAIL_USE_SSL = True + +""" +:::::::: .env file content :::::::: +SQLALCHEMY_DATABASE_URI=sqlite:///db.sqlite3 +SQLALCHEMY_TRACK_MODIFICATIONS=False +SECRET_KEY=randomKey + +MAIL_SERVER=smtp.gmail.com +MAIL_PORT=465 +MAIL_USERNAME=email@email.com +MAIL_PASSWORD=password +""" diff --git a/app/tests/api_testing/test_api.py b/app/tests/api_testing/test_api.py new file mode 100644 index 0000000..8a9b766 --- /dev/null +++ b/app/tests/api_testing/test_api.py @@ -0,0 +1,81 @@ +# ------- standard library imports ------- +import json +import unittest + +# ------- local imports ------- +from time import sleep + +from app.server.db.extensions import db +from app.server.db.models import Url +from app.app import create_app +from app.tests.api_testing.api_helper import ApiHelper + + +class TestApp(unittest.TestCase): + VALID_URL = 'youtube.com' + INVALID_URL = 'www.youtube.com/what?a=b&c=d' + INVALID_PARAM = 'INVALID' + + def setUp(self): + self.helper = ApiHelper() + self.app = create_app(config_file='settings.py') + sleep(1) + self.key = self.helper.get_auth_token(self.app) + + def test_01_shorten_url_success(self): + response = self.app.test_client().post( + '/api/shorten', + headers={'Authorization': f'Bearer {self.key}'}, + data={'url': self.VALID_URL} + ) + + res_dict = json.loads(response.get_data(as_text=True)) + + short_url = res_dict['short_url'] + original_url = res_dict['original_url'] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(short_url), 5) + self.assertEqual(original_url, 'http://youtube.com') + + def test_02_shorten_url_fail(self): + response = self.app.test_client().post( + '/api/shorten', + headers={'Authorization': f'Bearer {self.key}'}, + data={'url': self.INVALID_URL}, + ) + + res_dict = json.loads(response.get_data(as_text=True)) + + self.assertEqual(response.status_code, 404) + self.assertEqual(res_dict['success'], False) + self.assertEqual(res_dict['message'], 'could not shorten this URL (page_not_found)') + + def test_03_total_clicks(self): + # add url to db + response = self.app.test_client().post( + '/api/shorten', + headers={'Authorization': f'Bearer {self.key}'}, + data={'url': 'youtube.com'}, + ) + + with self.app.app_context(): + url = Url.query.filter_by(original_url='http://youtube.com').first() + short_url = url.short_url + + response = self.app.test_client().get( + '/api/total_clicks', + data={'url': short_url}, + ) + + res_dict = json.loads(response.get_data(as_text=True)) + self.assertEqual(res_dict['total'], 0) + + def tearDown(self): + db.session.remove() + with self.app.app_context(): + db.drop_all() + + +if __name__ == '__main__': + unittest.main() diff --git a/app/tests/front_end_testing/__init__.py b/app/tests/front_end_testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/front_end_testing/index/__init__.py b/app/tests/front_end_testing/index/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/front_end_testing/index/index.py b/app/tests/front_end_testing/index/index.py new file mode 100644 index 0000000..d405335 --- /dev/null +++ b/app/tests/front_end_testing/index/index.py @@ -0,0 +1,40 @@ +from app.tests.utilities import selenium_utility + + +class Index(selenium_utility.SeleniumUtility): + _heading_locator = '//p[@id="heading-p"]' + _url_input_locator = '//input[@id="url-input"]' + _shorten_button_locator = '//button[@type="submit"]' + _enter_url_warning = '//div[@class="alert-box alert-warning"]' + _try_again_button = '//button[@id="try-again-btn"]' + + def __init__(self, driver): + self.driver = driver + super().__init__(driver) + self.url_input = self.get_element(self._url_input_locator) + self.shorten_button = self.get_element(self._shorten_button_locator) + + def get_heading_text(self): + return self.get_element(self._heading_locator).text + + def enter_valid_url(self): + self.url_input = self.get_element(self._url_input_locator) + self.url_input.click() + self.url_input.send_keys('youtube.com') + + def enter_invalid_url(self): + self.url_input.click() + self.url_input.send_keys('https://www.youtube.com/what?a=b&c=d') + + def click_shorten_button(self): + self.shorten_button = self.get_element(self._shorten_button_locator) + self.shorten_button.click() + + def check_warning_present(self): + return self.get_element(self._enter_url_warning).is_displayed() + + def get_current_url(self): + return self.driver.current_url.split('/')[-1] + + def click_try_again(self): + self.wait_for_element(self._try_again_button).click() diff --git a/app/tests/front_end_testing/result/__init__.py b/app/tests/front_end_testing/result/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/front_end_testing/result/result.py b/app/tests/front_end_testing/result/result.py new file mode 100644 index 0000000..f35a1a9 --- /dev/null +++ b/app/tests/front_end_testing/result/result.py @@ -0,0 +1,29 @@ +from app.tests.utilities import selenium_utility +import pyperclip as pc + + +class Result(selenium_utility.SeleniumUtility): + _url_input_locator = '//input[@id="copy-able"]' + _copy_button_locator = '//button[@id="copy-btn"]' + _total_clicks_url = '//a[@id="total-clicks-link"]' + + def __init__(self, driver): + self.driver = driver + super().__init__(driver) + + self.url_input = self.get_element(self._url_input_locator) + self.copy_button = self.get_element(self._copy_button_locator) + + def get_input_text(self): + return self.get_element(self._url_input_locator).get_attribute('value') + + def click_copy_button(self): + self.copy_button.click() + + def go_to_total_clicks(self): + self.get_element(self._total_clicks_url).click() + + @staticmethod + def get_clipboard_content(): + text = pc.paste() + return text diff --git a/app/tests/front_end_testing/test_front_end.py b/app/tests/front_end_testing/test_front_end.py new file mode 100644 index 0000000..22b9e9c --- /dev/null +++ b/app/tests/front_end_testing/test_front_end.py @@ -0,0 +1,78 @@ +# ------- standard library imports ------- +import unittest +import multiprocessing + +# ------- 3rd party imports ------- +from selenium import webdriver +from flask_testing import LiveServerTestCase + +# ------- local imports ------- +from app.app import create_app +from app.tests.front_end_testing.index.index import Index +from app.tests.front_end_testing.result.result import Result +from app.tests.front_end_testing.total_clicks.total_clicks import TotalClicks + + +class TestAppSuccess(LiveServerTestCase, unittest.TestCase): + multiprocessing.set_start_method("fork") + + def create_app(self): + app = create_app(config_file='tests/front_end_testing/settings.py') + app.testing = True + app.config.update(LIVESERVER_PORT=5002) + return app + + @classmethod + def setUpClass(cls): + cls.chrome_browser = webdriver.Chrome() + + def test(self): + try: + """Test the index page.""" + self.chrome_browser.get(self.get_server_url()) + index = Index(self.chrome_browser) + # test that empty input shows error + index.click_shorten_button() + self.assertEqual(index.check_warning_present(), True) + + # test that an invalid url redirecrt to error page + index.enter_invalid_url() + index.click_shorten_button() + self.assertEqual(index.get_current_url(), 'error') + # Go back to home page + index.click_try_again() + + # check if heading is present + heading = index.get_heading_text() + self.assertEqual(heading, + 'ShortMe is a free tool to shorten URLs. Create a short & memorable URL in seconds.') + + # test that a valid URL is working + index.enter_valid_url() + index.click_shorten_button() + + """Test the result page once the long URL has been shortened""" + result = Result(self.chrome_browser) + # test that the short URL exist inside the input element + short_url = result.get_input_text() + self.assertIsNotNone(short_url) + + # test that the copy button works + result.click_copy_button() + clipboard_content = result.get_clipboard_content() + self.assertEqual(clipboard_content, short_url) + + # go to the total_clicks page + result.go_to_total_clicks() + + total_clicks = TotalClicks(self.chrome_browser) + # test that the text matches the expected + p_text = total_clicks.get_total_paragraph_text() + self.assertEqual(p_text, 'Your URL has been clicked 0 times so far.') + + finally: + self.chrome_browser.quit() + + +if __name__ == '__main__': + unittest.main() diff --git a/app/tests/front_end_testing/total_clicks/__init__.py b/app/tests/front_end_testing/total_clicks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/front_end_testing/total_clicks/total_clicks.py b/app/tests/front_end_testing/total_clicks/total_clicks.py new file mode 100644 index 0000000..4470581 --- /dev/null +++ b/app/tests/front_end_testing/total_clicks/total_clicks.py @@ -0,0 +1,12 @@ +from app.tests.utilities import selenium_utility + + +class TotalClicks(selenium_utility.SeleniumUtility): + _url_input_locator = '//p[@id="total-p"]' + + def __init__(self, driver): + self.driver = driver + super().__init__(driver) + + def get_total_paragraph_text(self): + return self.get_element(self._url_input_locator).text diff --git a/app/tests/utilities/__init__.py b/app/tests/utilities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/utilities/logger.py b/app/tests/utilities/logger.py new file mode 100644 index 0000000..d7d5725 --- /dev/null +++ b/app/tests/utilities/logger.py @@ -0,0 +1,20 @@ +import inspect +import logging + + +def Logger(log_level=logging.INFO): + # Gets the name of the class / method from where this method is called + logger_name = inspect.stack()[1][3] + logger = logging.getLogger(logger_name) + # By default, log all messages + logger.setLevel(logging.DEBUG) + + file_handler = logging.FileHandler("{0}.log".format(logger_name), mode='w') + file_handler.setLevel(log_level) + + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s: %(message)s', + datefmt='%m/%d/%Y %I:%M:%S %p') + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger diff --git a/app/tests/utilities/selenium_utility.py b/app/tests/utilities/selenium_utility.py new file mode 100644 index 0000000..5d9d36d --- /dev/null +++ b/app/tests/utilities/selenium_utility.py @@ -0,0 +1,137 @@ +# ------- standard library imports ------- +import logging +from pathlib import Path + +# ------- 3rd party imports ------- +from selenium.common.exceptions import * +from selenium.webdriver.common.by import By +from time import sleep, strftime, localtime +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.ui import Select +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.action_chains import ActionChains + +# ------- local imports ------- +from app.tests.utilities import logger + + +class SeleniumUtility: + """ + This utilitie is used to make it easier to use Selenium + """ + + log = logger.Logger(logging.DEBUG) + + def __init__(self, driver): + self.driver = driver + self.actions = ActionChains(driver) + + @staticmethod + def _get_by_type(locator_type): + """Returning the By type. xpath -> By.XPATH""" + return getattr(By, locator_type.upper()) + + def send_key_command(self, element, key): + """Sending key commands to a pre-found element. Keys.RETURN""" + try: + element.send_keys(getattr(Keys, key.upper())) + self.log.info(f'Key: {key} sent to element: {element}') + + except AttributeError as e: + print(e) + self.log.info(f'Could not send keys to {element}') + + def take_screenshot(self, sleep_time=0): + sleep(sleep_time) + Path("screenshots").mkdir(exist_ok=True) + t = localtime() + current_time = str(strftime("%H:%M:%S", t)) + file_name = ''.join([current_time, '.png']) + screenshot_directory = "screenshots" + destination_file = '/'.join([screenshot_directory, file_name]) + self.driver.save_screenshot(destination_file) + self.log.info(f'screenshot saved to {destination_file}') + + def get_element(self, locator, locator_type='xpath'): + """Return found element""" + by_type = self._get_by_type(locator_type) + + try: + element = self.driver.find_element(by_type, locator) + self.log.info(f'Element found. Locator: {locator}, Loctor type: {locator_type}') + return element + + except NoSuchElementException as e: + print(e) + self.log.info(f'Element not found. Locator: {locator}, Loctor type: {locator_type}') + + except Exception as e: + print(e) + self.log.info(f'Error while locating {locator}. {e}') + + def get_elements(self, locator, locator_type='xpath'): + """Return matching elements""" + by_type = self._get_by_type(locator_type) + try: + elements = self.driver.find_elements(by_type, locator) + return elements + + except NoSuchElementException as e: + print(e) + self.log.info(f'Element not found. Locator: {locator}, Loctor type: {locator_type}') + + except Exception as e: + print(e) + self.log.info(f'Error while locating {locator}. {e}') + + def scroll_to_element(self, locator, locator_type='xpath'): + """Scroll to matching element""" + element = self.get_element(locator, locator_type) + if element: + self.actions.move_to_element(element).perform() + + def deselct_dropdown(self, locator, locator_type='xpath'): + """deselect all options from that SELECT on the page""" + select = Select(self.get_element(locator, locator_type)) + select.deselect_all() + + def dropdown_select(self, + locator, + locator_type, + by_index=False, + by_visible_text=False, + by_value=False): + + select = Select(self.get_element(locator, locator_type)) + if by_index: + select.select_by_index(by_index) + elif by_visible_text: + select.select_by_visible_text(by_visible_text) + elif by_value: + select.select_by_value(by_value) + + def wait_for_element(self, locator, + locator_type='xpath', + timeout=10, + poll_frequency=0.5): + """Wait to presence of an element""" + try: + by_type = self._get_by_type(locator_type) + + wait = WebDriverWait(self.driver, + timeout, + poll_frequency, + ignored_exceptions=[NoSuchElementException, + ElementNotVisibleException, + ElementNotSelectableException]) + + element = wait.until(EC.element_to_be_clickable((by_type, locator))) + self.log.info(f'Element found. Locator: {locator}, Loctor type: {locator_type}') + return element + + except TimeoutException: + self.log.info('time out exception') + + except InvalidArgumentException: + self.log.info(f'Element not found. Locator: {locator}, Loctor type: {locator_type}') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..efc0f39 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +version: '3' + +services: + app: + build: ./ + ports: + - "5000:5000" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b80c981 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,40 @@ +python-dotenv==0.15.0 +aniso8601==8.1.0 +astroid==2.4.2 +blinker==1.4 +certifi==2020.11.8 +chardet==3.0.4 +click==7.1.2 +flake8==3.8.4 +Flask==1.1.2 +Flask-HTTPAuth==4.2.0 +Flask-Mail==0.9.1 +Flask-RESTful==0.3.8 +Flask-SQLAlchemy==2.5.1 +Flask-Testing==0.8.1 +gunicorn==20.0.4 +idna==2.10 +importlib-metadata==3.7.3 +isort==5.6.4 +itsdangerous==1.1.0 +Jinja2==2.11.2 +lazy-object-proxy==1.4.3 +MarkupSafe==1.1.1 +mccabe==0.6.1 +pycodestyle==2.6.0 +pyflakes==2.2.0 +PyJWT==1.4.2 +pylint==2.6.0 +pyperclip==1.8.1 +python-dotenv==0.15.0 +pytz==2020.4 +requests==2.25.0 +selenium==3.141.0 +six==1.15.0 +SQLAlchemy==1.3.20 +toml==0.10.2 +typed-ast==1.4.1 +urllib3==1.26.2 +Werkzeug==1.0.1 +wrapt==1.12.1 +zipp==3.4.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..eb8d851 --- /dev/null +++ b/run.py @@ -0,0 +1,5 @@ +# ------- local imports ------- +from app.app import app + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5555)