diff --git a/.github/workflows/trial_artmng.yml b/.github/workflows/trial_artmng.yml new file mode 100644 index 000000000..d1e5754d0 --- /dev/null +++ b/.github/workflows/trial_artmng.yml @@ -0,0 +1,76 @@ +# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy +# More GitHub Actions for Azure: https://github.com/Azure/actions +# More info on Python, GitHub Actions, and Azure App Service: https://aka.ms/python-webapps-actions + +name: Build and deploy Python app to Azure Web App - artmng + +on: + push: + branches: + - trial + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read #This is required for actions/checkout + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python version + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + # 🛠️ Local Build Section (Optional) + # The following section in your workflow is designed to catch build issues early on the client side, before deployment. This can be helpful for debugging and validation. However, if this step significantly increases deployment time and early detection is not critical for your workflow, you may remove this section to streamline the deployment process. + - name: Create and Start virtual environment and Install dependencies + run: | + python -m venv antenv + source antenv/bin/activate + pip install -r requirements.txt + + # By default, when you enable GitHub CI/CD integration through the Azure portal, the platform automatically sets the SCM_DO_BUILD_DURING_DEPLOYMENT application setting to true. This triggers the use of Oryx, a build engine that handles application compilation and dependency installation (e.g., pip install) directly on the platform during deployment. Hence, we exclude the antenv virtual environment directory from the deployment artifact to reduce the payload size. + - name: Upload artifact for deployment jobs + uses: actions/upload-artifact@v4 + with: + name: python-app + path: | + . + !antenv/ + + # 🚫 Opting Out of Oryx Build + # If you prefer to disable the Oryx build process during deployment, follow these steps: + # 1. Remove the SCM_DO_BUILD_DURING_DEPLOYMENT app setting from your Azure App Service Environment variables. + # 2. Refer to sample workflows for alternative deployment strategies: https://github.com/Azure/actions-workflow-samples/tree/master/AppService + + + deploy: + runs-on: ubuntu-latest + needs: build + permissions: + id-token: write #This is required for requesting the JWT + contents: read #This is required for actions/checkout + + steps: + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: python-app + + - name: Login to Azure + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_169DE98B41014829AB2C11AA0F6C5290 }} + tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_6AD74AE587804472A09570B3A8FC0984 }} + subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_82306B0D00B5492E9D05EB24E9259DCE }} + + - name: 'Deploy to Azure Web App' + uses: azure/webapps-deploy@v3 + id: deploy-to-webapp + with: + app-name: 'artmng' + slot-name: 'Production' + \ No newline at end of file diff --git a/FlaskWebProject/__init__.py b/FlaskWebProject/__init__.py index 05518a764..4901cf001 100644 --- a/FlaskWebProject/__init__.py +++ b/FlaskWebProject/__init__.py @@ -11,6 +11,9 @@ app = Flask(__name__) app.config.from_object(Config) # TODO: Add any logging levels and handlers with app.logger +streamHandler = logging.StreamHandler() +streamHandler.setLevel(logging.INFO) +app.logger.addHandler(streamHandler) Session(app) db = SQLAlchemy(app) login = LoginManager(app) diff --git a/FlaskWebProject/forms.py b/FlaskWebProject/forms.py index 3b8c9ee22..19ca39ad0 100644 --- a/FlaskWebProject/forms.py +++ b/FlaskWebProject/forms.py @@ -13,6 +13,7 @@ class LoginForm(FlaskForm): class PostForm(FlaskForm): title = StringField('Title', validators=[DataRequired()]) + subtitle = StringField('Subtitle', validators=[DataRequired()]) author = StringField('Author', validators=[DataRequired()]) body = TextAreaField('Body', validators=[DataRequired()]) image_path = FileField('Image', validators=[FileAllowed(['jpg', 'png'], 'Images only!')]) diff --git a/FlaskWebProject/models.py b/FlaskWebProject/models.py index f56495be8..6cd930340 100644 --- a/FlaskWebProject/models.py +++ b/FlaskWebProject/models.py @@ -36,6 +36,7 @@ class Post(db.Model): __tablename__ = 'posts' id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(150)) + subtitle = db.Column(db.String(150)) author = db.Column(db.String(75)) body = db.Column(db.String(800)) image_path = db.Column(db.String(100)) @@ -47,6 +48,7 @@ def __repr__(self): def save_changes(self, form, file, userId, new=False): self.title = form.title.data + self.subtitle = form.subtitle.data self.author = form.author.data self.body = form.body.data self.user_id = userId diff --git a/FlaskWebProject/templates/index.html b/FlaskWebProject/templates/index.html index 4dbf4dd66..08c186fb4 100644 --- a/FlaskWebProject/templates/index.html +++ b/FlaskWebProject/templates/index.html @@ -8,6 +8,7 @@
+ {{ form.subtitle.label }}
+ {{ form.subtitle(size=64) }}
+ {% for error in form.subtitle.errors %}
+ [{{ error }}]
+ {% endfor %}
+
{{ form.author.label }}
{{ form.author(size=32) }}
diff --git a/FlaskWebProject/views.py b/FlaskWebProject/views.py
index 62be10a8c..d65381d86 100644
--- a/FlaskWebProject/views.py
+++ b/FlaskWebProject/views.py
@@ -67,11 +67,13 @@ def login():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
+ app.logger.warning('Invalid login attempt for user: {}'.format(form.username.data))
return redirect(url_for('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('home')
+ app.logger.warning('User logged in successfully : {}'.format(user.username))
return redirect(next_page)
session["state"] = str(uuid.uuid4())
auth_url = _build_auth_url(scopes=Config.SCOPE, state=session["state"])
@@ -86,8 +88,12 @@ def authorized():
if request.args.get('code'):
cache = _load_cache()
# TODO: Acquire a token from a built msal app, along with the appropriate redirect URI
- result = None
+ result = _build_msal_app(cache=cache).acquire_token_by_authorization_code(
+ request.args['code'],
+ scopes=Config.SCOPE,
+ redirect_uri=url_for('authorized', _external=True, _scheme='https'))
if "error" in result:
+ app.logger.warning('Azure AD authentication failed')
return render_template("auth_error.html", result=result)
session["user"] = result.get("id_token_claims")
# Note: In a real app, we'd use the 'name' property from session["user"] below
@@ -95,6 +101,7 @@ def authorized():
user = User.query.filter_by(username="admin").first()
login_user(user)
_save_cache(cache)
+ app.logger.warning('User logged in via Azure AD')
return redirect(url_for('home'))
@app.route('/logout')
@@ -112,17 +119,27 @@ def logout():
def _load_cache():
# TODO: Load the cache from `msal`, if it exists
- cache = None
+ cache = msal.SerializableTokenCache()
+ if session.get('token_cache'):
+ cache.deserialize(session['token_cache'])
return cache
def _save_cache(cache):
# TODO: Save the cache, if it has changed
- pass
+ if cache.has_state_changed:
+ session['token_cache'] = cache.serialize()
def _build_msal_app(cache=None, authority=None):
# TODO: Return a ConfidentialClientApplication
- return None
+ return msal.ConfidentialClientApplication(
+ Config.CLIENT_ID,
+ authority=authority or Config.AUTHORITY,
+ client_credential=Config.CLIENT_SECRET,
+ token_cache=cache)
def _build_auth_url(authority=None, scopes=None, state=None):
# TODO: Return the full Auth Request URL with appropriate Redirect URI
- return None
+ return _build_msal_app(authority=authority).get_authorization_request_url(
+ scopes or [],
+ state=state or str(uuid.uuid4()),
+ redirect_uri=url_for('authorized', _external=True, _scheme='https'))
\ No newline at end of file
diff --git a/WRITEUP.md b/WRITEUP.md
index ae6a57632..6af0cad4b 100644
--- a/WRITEUP.md
+++ b/WRITEUP.md
@@ -7,6 +7,30 @@
- *Choose the appropriate solution (VM or App Service) for deploying the app*
- *Justify your choice*
+#### Azure Virtual Machine (IaaS)
+- Cost is higher
+- Scalability and Availability are developer's responsibility, managed via VM scale sets and load balancers
+- Full control over VM but heavy operational overhead
+
+#### Azure App Service (PaaS)
+- Cost is lower and more predicatable
+- Scalability and Availability are automatic and fast
+- Almost zero server management
+
+#### Chosen solution
+**Azure App Service (Web App)**
+##### Justification for choosing App Service over VM
+- The application is described as a lightweight article management system so it's a perfect fit for PaaS.
+- No need for access or control over the OS.
+- Significantly lower operational overhead.
+- Faster and cheaper scaling.
+- Lower total cost.
+- Higher SLA and better availability.
+
+
### Assess app changes that would change your decision.
-*Detail how the app and any other needs would have to change for you to change your decision in the last section.*
\ No newline at end of file
+*Detail how the app and any other needs would have to change for you to change your decision in the last section.*
+
+- Requirement for accessing or controlling OS.
+- Extremly high compute requirements the exceed the App Service limits.
\ No newline at end of file
diff --git a/config.py b/config.py
index 0b2223b51..1da4a0dec 100644
--- a/config.py
+++ b/config.py
@@ -5,21 +5,21 @@
class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or 'secret-key'
- BLOB_ACCOUNT = os.environ.get('BLOB_ACCOUNT') or 'ENTER_STORAGE_ACCOUNT_NAME'
- BLOB_STORAGE_KEY = os.environ.get('BLOB_STORAGE_KEY') or 'ENTER_BLOB_STORAGE_KEY'
- BLOB_CONTAINER = os.environ.get('BLOB_CONTAINER') or 'ENTER_IMAGES_CONTAINER_NAME'
-
- SQL_SERVER = os.environ.get('SQL_SERVER') or 'ENTER_SQL_SERVER_NAME.database.windows.net'
- SQL_DATABASE = os.environ.get('SQL_DATABASE') or 'ENTER_SQL_DB_NAME'
- SQL_USER_NAME = os.environ.get('SQL_USER_NAME') or 'ENTER_SQL_SERVER_USERNAME'
- SQL_PASSWORD = os.environ.get('SQL_PASSWORD') or 'ENTER_SQL_SERVER_PASSWORD'
+ BLOB_ACCOUNT = os.environ.get('BLOB_ACCOUNT') or 'artmngstorageaccount'
+ BLOB_STORAGE_KEY = os.environ.get('BLOB_STORAGE_KEY') or 'hah4cR+NDvmF8Df6e1e1jNcoI+/yAb1Sy4nT0PpGZ6PQ4uPq+lxwJT+7YJJAjH1QP3hHqkPcLMz/+ASta+7eTw=='
+ BLOB_CONTAINER = os.environ.get('BLOB_CONTAINER') or 'images'
+
+ SQL_SERVER = os.environ.get('SQL_SERVER') or 'article-management-server.database.windows.net'
+ SQL_DATABASE = os.environ.get('SQL_DATABASE') or 'article-management-db'
+ SQL_USER_NAME = os.environ.get('SQL_USER_NAME') or 'artmngadmin'
+ SQL_PASSWORD = os.environ.get('SQL_PASSWORD') or 'AMS357#ams'
# Below URI may need some adjustments for driver version, based on your OS, if running locally
SQLALCHEMY_DATABASE_URI = 'mssql+pyodbc://' + SQL_USER_NAME + '@' + SQL_SERVER + ':' + SQL_PASSWORD + '@' + SQL_SERVER + ':1433/' + SQL_DATABASE + '?driver=ODBC+Driver+17+for+SQL+Server'
SQLALCHEMY_TRACK_MODIFICATIONS = False
### Info for MS Authentication ###
### As adapted from: https://github.com/Azure-Samples/ms-identity-python-webapp ###
- CLIENT_SECRET = "ENTER_CLIENT_SECRET_HERE"
+ CLIENT_SECRET = "Uhf8Q~u0xgqNK-BtoCC16VerEeez7XQU00AuXbvN"
# In your production app, Microsoft recommends you to use other ways to store your secret,
# such as KeyVault, or environment variable as described in Flask's documentation here:
# https://flask.palletsprojects.com/en/1.1.x/config/#configuring-from-environment-variables
@@ -30,7 +30,7 @@ class Config(object):
AUTHORITY = "https://login.microsoftonline.com/common" # For multi-tenant app, else put tenant name
# AUTHORITY = "https://login.microsoftonline.com/Enter_the_Tenant_Name_Here"
- CLIENT_ID = "ENTER_CLIENT_ID_HERE"
+ CLIENT_ID = "91ab10c6-b9ea-46aa-a138-aaec5aad33ed"
REDIRECT_PATH = "/getAToken" # Used to form an absolute URL; must match to app's redirect_uri set in AAD
diff --git a/requirements.txt b/requirements.txt
index df94cccfd..edb080690 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,9 +1,7 @@
-alembic==1.4.0
azure-common==1.1.24
azure-nspkg==3.0.2
azure-storage==0.36.0
certifi==2019.11.28
-cffi==1.14.6
chardet==3.0.4
Click==7.1.2
cryptography==2.8
@@ -15,7 +13,6 @@ Flask-WTF==0.14.3
idna==2.8
itsdangerous==2.0.1
Jinja2==3.0.1
-Mako==1.1.1
MarkupSafe==2.0.1
msal==1.13.0
pip==10.0.1
diff --git a/screenshots/article-cms-solution.png b/screenshots/article-cms-solution.png
new file mode 100644
index 000000000..bba915d62
Binary files /dev/null and b/screenshots/article-cms-solution.png differ
diff --git a/screenshots/azure-portal-resource-group-solution.png b/screenshots/azure-portal-resource-group-solution.png
new file mode 100644
index 000000000..78d82c12d
Binary files /dev/null and b/screenshots/azure-portal-resource-group-solution.png differ
diff --git a/screenshots/blob-solution.png b/screenshots/blob-solution.png
new file mode 100644
index 000000000..14171df03
Binary files /dev/null and b/screenshots/blob-solution.png differ
diff --git a/screenshots/invalid-login-log-solution.png b/screenshots/invalid-login-log-solution.png
new file mode 100644
index 000000000..d73e5d0d0
Binary files /dev/null and b/screenshots/invalid-login-log-solution.png differ
diff --git a/screenshots/sql-storage-solution.png b/screenshots/sql-storage-solution.png
new file mode 100644
index 000000000..166922391
Binary files /dev/null and b/screenshots/sql-storage-solution.png differ
diff --git a/screenshots/successful-login-log-solution.png b/screenshots/successful-login-log-solution.png
new file mode 100644
index 000000000..6ac293495
Binary files /dev/null and b/screenshots/successful-login-log-solution.png differ
diff --git a/screenshots/uri-redirects-solution.png b/screenshots/uri-redirects-solution.png
new file mode 100644
index 000000000..3d37d6d58
Binary files /dev/null and b/screenshots/uri-redirects-solution.png differ
diff --git a/sql_scripts/posts-table-init.sql b/sql_scripts/posts-table-init.sql
index 7825431de..e6fe30ce8 100644
--- a/sql_scripts/posts-table-init.sql
+++ b/sql_scripts/posts-table-init.sql
@@ -1,6 +1,7 @@
CREATE TABLE POSTS(
id INT NOT NULL IDENTITY(1, 1),
title VARCHAR(150) NOT NULL,
+ subtitle VARCHAR(250) NOT NULL,
author VARCHAR(75) NOT NULL,
body VARCHAR(800) NOT NULL,
image_path VARCHAR(100) NULL,
@@ -10,9 +11,10 @@ CREATE TABLE POSTS(
FOREIGN KEY (user_id) REFERENCES users(id)
);
-INSERT INTO dbo.posts (title, author, body, user_id)
+INSERT INTO dbo.posts (title, subtitle, author, body, user_id)
VALUES (
'Lorem ipsum dolor sit amet',
+ 'here is additional subtitle',
'John Smith',
'Proin sit amet mi ornare, ultrices augue quis, facilisis tellus. Quisque neque dui, tincidunt sed volutpat quis, maximus sed est. Sed justo orci, rhoncus ac nulla eu, rhoncus luctus justo. Etiam maximus, felis eu varius fermentum, libero orci egestas purus, id condimentum mauris orci nec nibh. Vivamus risus ipsum, semper vel nibh in, suscipit commodo massa. Suspendisse non velit vitae neque condimentum viverra vel eget enim. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Vivamus fermentum sagittis ligula et fringilla. Aenean nec lacinia lacus.',
1