Merge branch 'main' into favourite_posts
# Conflicts: # requirements.txt
This commit is contained in:
commit
139c647d4a
|
@ -38,3 +38,5 @@ nosetests.xml
|
|||
venv
|
||||
app.db
|
||||
microblog.log*
|
||||
.env
|
||||
.idea/
|
|
@ -9,6 +9,7 @@ RUN python -m venv venv
|
|||
RUN venv/bin/pip install -r requirements.txt
|
||||
RUN venv/bin/pip install gunicorn pymysql cryptography
|
||||
|
||||
COPY .env .env
|
||||
COPY app app
|
||||
COPY migrations migrations
|
||||
COPY microblog.py config.py boot.sh ./
|
||||
|
@ -16,6 +17,10 @@ RUN chmod a+x boot.sh
|
|||
|
||||
ENV FLASK_APP microblog.py
|
||||
|
||||
CMD flask db upgrade
|
||||
CMD flask db migrate -m "two-factor authentication"
|
||||
CMD flask db upgrade
|
||||
|
||||
RUN chown -R microblog:microblog ./
|
||||
USER microblog
|
||||
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
pipeline {
|
||||
agent any
|
||||
|
||||
stages {
|
||||
stage('Undeploy') {
|
||||
steps {
|
||||
// Stop running microblog container (microblog label applied to microblog container by this repo's Dockerfile)
|
||||
sh 'docker stop $(docker ps -q --filter name=microblog) || true && docker rm $(docker ps -q --filter name=microblog) || true'
|
||||
}
|
||||
}
|
||||
stage('Build') {
|
||||
steps {
|
||||
sh 'docker build -t microblog:latest .'
|
||||
}
|
||||
}
|
||||
stage('Deploy') {
|
||||
steps {
|
||||
// Deploy new container
|
||||
sh 'docker run --name microblog -d -p 5000:5000 --rm microblog:latest'
|
||||
// Remove all images except for jenkins
|
||||
sh 'docker image prune -af --filter "label!=org.opencontainers.image.vendor=Jenkins project"'
|
||||
}
|
||||
}
|
||||
stage('Selenium Tests') {
|
||||
steps {
|
||||
echo 'Running Selenium Tests...'
|
||||
//sh 'python3 selenium.py'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post always runs even if the pipeline fails
|
||||
post {
|
||||
success {
|
||||
echo 'Pipeline has completed'
|
||||
discordSend description: '', footer: '', image: '', link: 'http://3.220.122.102:8080/', result: '', scmWebUrl: '', thumbnail: '', title:
|
||||
'Pipeline has completed ✅', webhookURL: 'https://discord.com/api/webhooks/1075879011667955872/Nk0gmKZkrISEs-hru-HjtzzgezWweABCdPsOKGIzkmj5xMcqKC3m1-dx7GZSu0yURAOo'
|
||||
}
|
||||
failure {
|
||||
echo 'Something has failed!'
|
||||
discordSend description: 'Pipeline has failed!', footer: '', image: '', link: 'http://3.220.122.102:8080/', result: '', scmWebUrl: '', thumbnail: '', title:
|
||||
'Pipeline failure ❌', webhookURL: 'https://discord.com/api/webhooks/1075879011667955872/Nk0gmKZkrISEs-hru-HjtzzgezWweABCdPsOKGIzkmj5xMcqKC3m1-dx7GZSu0yURAOo'
|
||||
}
|
||||
}
|
||||
}
|
29
README.md
29
README.md
|
@ -1,3 +1,32 @@
|
|||
# Welcome to Microblog!
|
||||
|
||||
This is an example application featured in my [Flask Mega-Tutorial](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world). See the tutorial for instructions on how to work with it.
|
||||
|
||||
# How to run behave tutorial
|
||||
|
||||
*NOTE* behave steps in the .feature file MUST unicode match those in the 'steps' file
|
||||
|
||||
e.g. in the *.feature file there is a Gherkin line like
|
||||
'the task 'Buy groceries' with estimate '2' should be added to the todo list'
|
||||
|
||||
then in the accompanying *step.py file it must follow the exact unicode in the behave decorator
|
||||
Like this -> '@then("the task '{title}' with estimate '{estimate}' should be added to the todo list")'
|
||||
|
||||
|
||||
Also step files must follow the regex *step.py
|
||||
|
||||
1. Running all behave features
|
||||
|
||||
run " behave tests/features/add-task.feature "
|
||||
|
||||
# How to run unit tests
|
||||
|
||||
unit tests must follow the "test*.py" regex to be picked up
|
||||
|
||||
1. Individual tests
|
||||
|
||||
run " python ./ests/unit/test_example.py "
|
||||
|
||||
2. Run all unit tests
|
||||
|
||||
run " python -m unittest discover -s ./tests/unit "
|
|
@ -4,7 +4,6 @@ from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
|
|||
from flask_babel import _, lazy_gettext as _l
|
||||
from app.models import User
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField(_l('Username'), validators=[DataRequired()])
|
||||
password = PasswordField(_l('Password'), validators=[DataRequired()])
|
||||
|
@ -12,6 +11,20 @@ class LoginForm(FlaskForm):
|
|||
submit = SubmitField(_l('Sign In'))
|
||||
|
||||
|
||||
class Confirm2faForm(FlaskForm):
|
||||
token = StringField('Token')
|
||||
submit = SubmitField('Verify')
|
||||
|
||||
|
||||
class Enable2faForm(FlaskForm):
|
||||
verification_phone = StringField('Phone', validators=[DataRequired()])
|
||||
submit = SubmitField('Enable 2FA')
|
||||
|
||||
|
||||
class Disable2faForm(FlaskForm):
|
||||
submit = SubmitField('Disable 2FA')
|
||||
|
||||
|
||||
class RegistrationForm(FlaskForm):
|
||||
username = StringField(_l('Username'), validators=[DataRequired()])
|
||||
email = StringField(_l('Email'), validators=[DataRequired(), Email()])
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
from flask import render_template, redirect, url_for, flash, request
|
||||
from app.auth.twilio_verify import check_verification_token, request_verification_token
|
||||
from flask import render_template, redirect, url_for, flash, request, session
|
||||
from werkzeug.urls import url_parse
|
||||
from flask_login import login_user, logout_user, current_user
|
||||
from flask_login import login_user, login_required, logout_user, current_user
|
||||
from flask_babel import _
|
||||
from app import db
|
||||
from app.auth import bp
|
||||
from app.auth.forms import LoginForm, RegistrationForm, \
|
||||
from app.auth.forms import Confirm2faForm, Disable2faForm, Enable2faForm, LoginForm, RegistrationForm, \
|
||||
ResetPasswordRequestForm, ResetPasswordForm
|
||||
from app.models import User
|
||||
from app.auth.email import send_password_reset_email
|
||||
|
@ -20,14 +21,68 @@ def login():
|
|||
if user is None or not user.check_password(form.password.data):
|
||||
flash(_('Invalid username or password'))
|
||||
return redirect(url_for('auth.login'))
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or url_parse(next_page).netloc != '':
|
||||
next_page = url_for('main.index')
|
||||
if user.two_factor_enabled():
|
||||
request_verification_token(user.verification_phone)
|
||||
session['username'] = user.username
|
||||
session['phone'] = user.verification_phone
|
||||
return redirect(url_for(
|
||||
'auth.verify_2fa', next=next_page,
|
||||
remember='1' if form.remember_me.data else '0'))
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
return redirect(next_page)
|
||||
return render_template('auth/login.html', title=_('Sign In'), form=form)
|
||||
|
||||
|
||||
@bp.route('/verify_2fa', methods=['GET', 'POST'])
|
||||
def verify_2fa():
|
||||
form = Confirm2faForm()
|
||||
if form.validate_on_submit():
|
||||
phone = session['phone']
|
||||
if check_verification_token(phone, form.token.data):
|
||||
if current_user.is_authenticated:
|
||||
current_user.verification_phone = phone
|
||||
db.session.commit()
|
||||
flash('Two-factor authentication is now enabled')
|
||||
return redirect(url_for('main.index'))
|
||||
else:
|
||||
username = session['username']
|
||||
user = User.query.filter_by(username=username).first()
|
||||
next_page = request.args.get('next')
|
||||
remember = request.args.get('remember', '0') == '1'
|
||||
if not next_page or url_parse(next_page).netloc != '':
|
||||
next_page = url_for('main.index')
|
||||
login_user(user, remember=remember)
|
||||
return redirect(next_page)
|
||||
form.token.errors.append('Invalid token')
|
||||
return render_template('auth/verify_2fa.html', form=form)
|
||||
|
||||
|
||||
@bp.route('/enable_2fa', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def enable_2fa():
|
||||
form = Enable2faForm()
|
||||
if form.validate_on_submit():
|
||||
session['phone'] = form.verification_phone.data
|
||||
request_verification_token(session['phone'])
|
||||
return redirect(url_for('auth.verify_2fa'))
|
||||
return render_template('auth/enable_2fa.html', form=form)
|
||||
|
||||
|
||||
@bp.route('/disable_2fa', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def disable_2fa():
|
||||
form = Disable2faForm()
|
||||
if form.validate_on_submit():
|
||||
current_user.verification_phone = None
|
||||
db.session.commit()
|
||||
flash('Two-factor authentication is now disabled.')
|
||||
return redirect(url_for('main.index'))
|
||||
return render_template('auth/disable_2fa.html', form=form)
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
def logout():
|
||||
logout_user()
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
from flask import current_app
|
||||
from twilio.rest import Client, TwilioException
|
||||
|
||||
|
||||
def _get_twilio_verify_client():
|
||||
return Client(
|
||||
current_app.config['TWILIO_ACCOUNT_SID'],
|
||||
current_app.config['TWILIO_AUTH_TOKEN']).verify.services(
|
||||
current_app.config['TWILIO_VERIFY_SERVICE_ID'])
|
||||
|
||||
|
||||
def request_verification_token(phone):
|
||||
verify = _get_twilio_verify_client()
|
||||
try:
|
||||
verify.verifications.create(to=phone, channel='sms')
|
||||
except TwilioException:
|
||||
verify.verifications.create(to=phone, channel='call')
|
||||
|
||||
|
||||
def check_verification_token(phone, token):
|
||||
verify = _get_twilio_verify_client()
|
||||
try:
|
||||
result = verify.verification_checks.create(to=phone, code=token)
|
||||
except TwilioException:
|
||||
return False
|
||||
return result.status == 'approved'
|
|
@ -96,6 +96,7 @@ class User(UserMixin, PaginatedAPIMixin, db.Model):
|
|||
username = db.Column(db.String(64), index=True, unique=True)
|
||||
email = db.Column(db.String(120), index=True, unique=True)
|
||||
password_hash = db.Column(db.String(128))
|
||||
verification_phone = db.Column(db.String(16))
|
||||
posts = db.relationship('Post', backref='author', lazy='dynamic')
|
||||
# link archive class to user
|
||||
archived = db.relationship('Archive', foreign_keys='Archive.archived_by', backref='archivee', lazy='dynamic')
|
||||
|
@ -128,6 +129,9 @@ class User(UserMixin, PaginatedAPIMixin, db.Model):
|
|||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def two_factor_enabled(self):
|
||||
return self.verification_phone is not None
|
||||
|
||||
def avatar(self, size):
|
||||
digest = md5(self.email.lower().encode('utf-8')).hexdigest()
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
{# app/templates/auth/disable_2fa.html #}
|
||||
{% extends 'base.html' %}
|
||||
{% import 'bootstrap/wtf.html' as wtf %}
|
||||
|
||||
{% block app_content %}
|
||||
<h1>Disable Two-Factor Authentication</h1>
|
||||
<p>Please click the button below to disable two-factor authentication on your account.</p>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
{{ wtf.quick_form(form) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,34 @@
|
|||
{% extends 'base.html' %}
|
||||
{% import 'bootstrap/wtf.html' as wtf %}
|
||||
|
||||
{% block app_content %}
|
||||
<h1>Enable Two-Factor Authentication</h1>
|
||||
<p>Please enter your mobile number to activate two-factor authentication on your account.</p>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
{{ wtf.quick_form(form) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/intl-tel-input/16.0.4/css/intlTelInput.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/intl-tel-input/16.0.4/js/intlTelInput-jquery.min.js"></script>
|
||||
<script>
|
||||
$("#verification_phone").css({position: 'absolute', top: '-9999px', left: '-9999px'});
|
||||
$("#verification_phone").parent().append('<div><input type="tel" id="_verification_phone"></div>');
|
||||
$("#_verification_phone").intlTelInput({
|
||||
separateDialCode: true,
|
||||
utilsScript: "https://cdnjs.cloudflare.com/ajax/libs/intl-tel-input/16.0.4/js/utils.js",
|
||||
});
|
||||
$("#_verification_phone").intlTelInput("setNumber", $('#verification_phone').val());
|
||||
$('#_verification_phone').blur(function() {
|
||||
$('#verification_phone').val($('#_verification_phone').intlTelInput("getNumber"));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -0,0 +1,12 @@
|
|||
{% extends 'base.html' %}
|
||||
{% import 'bootstrap/wtf.html' as wtf %}
|
||||
|
||||
{% block app_content %}
|
||||
<h1>Two-Factor Authentication</h1>
|
||||
<p>Please enter the token that was sent to your phone.</p>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
{{ wtf.quick_form(form) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -35,6 +35,11 @@
|
|||
{% if user != current_user %}
|
||||
<p><a href="{{ url_for('main.send_message', recipient=user.username) }}">{{ _('Send private message') }}</a></p>
|
||||
{% endif %}
|
||||
{% if not user.two_factor_enabled() %}
|
||||
<p><a href="{{ url_for('auth.enable_2fa') }}">{{ _('Enable two-factor authentication') }}</a></p>
|
||||
{% else %}
|
||||
<p><a href="{{ url_for('auth.disable_2fa') }}">{{ _('Disable two-factor authentication') }}</a></p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
@ -11,6 +11,9 @@ class Config(object):
|
|||
'postgres://', 'postgresql://') or \
|
||||
'sqlite:///' + os.path.join(basedir, 'app.db')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
TWILIO_ACCOUNT_SID = os.environ.get('TWILIO_ACCOUNT_SID')
|
||||
TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_ACCOUNT_TOKEN')
|
||||
TWILIO_VERIFY_SERVICE_ID = os.environ.get('TWILIO_VERIFY_SERVICE_ID')
|
||||
LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT')
|
||||
MAIL_SERVER = os.environ.get('MAIL_SERVER')
|
||||
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
"""two-factor authentication
|
||||
|
||||
Revision ID: 23e9d8ca48c2
|
||||
Revises: 834b1a697901
|
||||
Create Date: 2023-03-04 14:26:06.269415
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '23e9d8ca48c2'
|
||||
down_revision = '834b1a697901'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('user', sa.Column('verification_phone', sa.String(length=16), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('user', 'verification_phone')
|
||||
# ### end Alembic commands ###
|
|
@ -1,13 +1,17 @@
|
|||
alembic==1.6.5
|
||||
async-generator==1.10
|
||||
attrs==22.2.0
|
||||
Babel==2.9.1
|
||||
blinker==1.4
|
||||
certifi==2021.5.30
|
||||
chardet==4.0.0
|
||||
charset-normalizer==3.0.1
|
||||
click==8.0.1
|
||||
dnspython==2.1.0
|
||||
dominate==2.6.0
|
||||
elasticsearch==7.13.3
|
||||
email-validator==1.1.3
|
||||
exceptiongroup==1.1.0
|
||||
Flask==2.0.1
|
||||
Flask-Babel==2.0.0
|
||||
Flask-Bootstrap==3.3.7.1
|
||||
|
@ -18,7 +22,8 @@ Flask-Migrate==3.0.1
|
|||
Flask-Moment==1.0.1
|
||||
Flask-SQLAlchemy==2.5.1
|
||||
Flask-WTF==0.15.1
|
||||
greenlet==2.0.1
|
||||
greenlet==2.0.2
|
||||
h11==0.14.0
|
||||
httpie==2.4.0
|
||||
idna==2.10
|
||||
itsdangerous==2.0.1
|
||||
|
@ -26,6 +31,7 @@ Jinja2==3.0.1
|
|||
langdetect==1.0.9
|
||||
Mako==1.1.4
|
||||
MarkupSafe==2.0.1
|
||||
outcome==1.2.0
|
||||
Pygments==2.9.0
|
||||
PyJWT==2.1.0
|
||||
PySocks==1.7.1
|
||||
|
@ -37,13 +43,19 @@ redis==3.5.3
|
|||
requests==2.25.1
|
||||
requests-toolbelt==0.9.1
|
||||
rq==1.9.0
|
||||
selenium==4.4.1
|
||||
selenium
|
||||
behave
|
||||
webdriver_manager
|
||||
six==1.16.0
|
||||
sniffio==1.3.0
|
||||
sortedcontainers==2.4.0
|
||||
SQLAlchemy==1.4.20
|
||||
trio==0.22.0
|
||||
trio-websocket==0.9.2
|
||||
twilio==7.16.4
|
||||
urllib3==1.26.6
|
||||
visitor==0.1.3
|
||||
Werkzeug==2.0.1
|
||||
wsproto==1.2.0
|
||||
WTForms==2.3.3
|
||||
|
||||
# requirements for Heroku
|
||||
#psycopg2==2.9.1
|
||||
#gunicorn==20.1.0
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
Feature: Add tasks to todo list
|
||||
As a user
|
||||
I want to be able to add tasks to my todo list
|
||||
So thats I can keep track of my work
|
||||
|
||||
Scenario: User visits the todo list page
|
||||
Given the user is on the todo list page
|
||||
Then the page should have a text field to enter the title of the task
|
||||
And the page should have a text field to enter the estimate of hours needed to complete the task
|
||||
And the page should have a button to add the task
|
||||
|
||||
Scenario: Add a new task to the todo list
|
||||
Given I am on the todo list page
|
||||
When I enter 'Buy groceries' in the title field
|
||||
And I enter '2' in the estimate field
|
||||
And I click the 'Add' button
|
||||
Then the task 'Buy groceries' with estimate '2' should be added to the todo list
|
|
@ -0,0 +1,54 @@
|
|||
from behave import given, when, then
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service as ChromeService
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
|
||||
#THESE ARE EXAMPLES FILES.
|
||||
#Todo delete these once we set a standard with our own tests
|
||||
|
||||
@given("I am on the todo list page")
|
||||
def open_browser(context):
|
||||
|
||||
# Implementation of headless from https://stackoverflow.com/questions/46920243/how-to-configure-chromedriver-to-initiate-chrome-browser-in-headless-mode-throug
|
||||
# Stackoverflow post desribes what is goin on with options to enable headless chrome
|
||||
|
||||
options = Options()
|
||||
options.add_argument("--headless") # Runs Chrome in headless mode.
|
||||
options.add_argument('--no-sandbox') # Bypass OS security model
|
||||
options.add_argument('start-maximized') #to maximize viewport this should still be headless
|
||||
options.add_argument('disable-infobars')
|
||||
options.add_argument("--disable-extensions")
|
||||
context.driver = webdriver.Chrome(options=options, service=ChromeService(ChromeDriverManager().install()))
|
||||
context.driver.implicitly_wait(5)
|
||||
context.driver.get("http://127.0.0.1:5000/")
|
||||
|
||||
|
||||
@when("I enter '{title}' in the title field")
|
||||
def step_impl(context, title):
|
||||
title_field = context.driver.find_element(By.NAME, "title")
|
||||
title_field.send_keys(title)
|
||||
|
||||
|
||||
@when("I enter '{estimate}' in the estimate field")
|
||||
def step_impl(context, estimate):
|
||||
estimate_field = context.driver.find_element(By.NAME, "estimate")
|
||||
estimate_field.send_keys(estimate)
|
||||
|
||||
|
||||
@when("I click the 'Add' button")
|
||||
def step_impl(context):
|
||||
add_button = context.driver.find_element(By.XPATH, "//button[contains(text(),'Add')]")
|
||||
add_button.click()
|
||||
context.driver.implicitly_wait(5)
|
||||
|
||||
|
||||
@then("the task '{title}' with estimate '{estimate}' should be added to the todo list")
|
||||
def step_impl(context, title, estimate):
|
||||
dump_text = context.driver.page_source
|
||||
print(dump_text)
|
||||
assert ("Buy groceries | 2" in dump_text) is True
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
from behave import given, when, then
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service as ChromeService
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
|
||||
#THESE ARE EXAMPLES FILES.
|
||||
#Todo delete these once we set a standard with our own tests
|
||||
|
||||
@given("the user is on the todo list page")
|
||||
def open_browser(context):
|
||||
options = Options()
|
||||
options.add_argument("--headless") # Runs Chrome in headless mode.
|
||||
options.add_argument('--no-sandbox') # Bypass OS security model
|
||||
options.add_argument('start-maximized') #to maximize viewport this should still be headless
|
||||
options.add_argument('disable-infobars')
|
||||
options.add_argument("--disable-extensions")
|
||||
context.driver = webdriver.Chrome(options=options, service=ChromeService(ChromeDriverManager().install()))
|
||||
context.driver.implicitly_wait(5)
|
||||
context.driver.get("http://127.0.0.1:5000/")
|
||||
|
||||
|
||||
@then("the page should have a text field to enter the title of the task")
|
||||
def check_task_title_textbox(context):
|
||||
status = context.driver.find_element(By.NAME, "title").is_displayed()
|
||||
assert status is True
|
||||
|
||||
|
||||
@then("the page should have a text field to enter the estimate of hours needed to complete the task")
|
||||
def check_task_estimate_textbox(context):
|
||||
status = context.driver.find_element(By.NAME, "estimate").is_displayed()
|
||||
assert status is True
|
||||
|
||||
|
||||
@then("the page should have a button to add the task")
|
||||
def check_task_add_button(context):
|
||||
status = context.driver.find_element(By.XPATH, "//button[contains(text(),'Add')]").is_displayed()
|
||||
assert status is True
|
|
@ -1,4 +1,7 @@
|
|||
#!/usr/bin/env python
|
||||
import sys
|
||||
sys.path.append('./')
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import unittest
|
||||
from app import create_app, db
|
Loading…
Reference in New Issue