├── .gitignore ├── streamlit ├── config.py ├── .streamlit │ ├── .credentials.IGNORE.toml │ └── config.IGNORE.toml ├── src │ ├── style │ │ ├── stringformats.py │ │ └── charts.py │ ├── pages │ │ ├── misc.py │ │ ├── goals.py │ │ ├── new_tracker.py │ │ └── entries.py │ └── crud.py ├── Pipfile ├── Dockerfile ├── user │ └── user.py ├── app.py ├── tracker │ └── tracker.py ├── sessionstate.py └── Pipfile.lock ├── api ├── alembic │ ├── README │ ├── script.py.mako │ ├── versions │ │ ├── a5ee42f3cea7_created_user_model.py │ │ ├── aaa8141c13b6_import_user_model_metadata_into_alembic.py │ │ ├── e9ac74c2fb4f_passing_user_model_thru_user_.py │ │ ├── 09d5d3f71cf4_created_user_model_but_imported_into_.py │ │ ├── 8d31408b2f13_fixed_metadata_bug_in_alembic_env_py_.py │ │ ├── e74329689b23_first_commit.py │ │ └── 2171019a778e_created_entries_db_model.py │ └── env.py ├── .env.template ├── Dockerfile ├── user │ ├── schema.py │ ├── model.py │ └── router.py ├── Pipfile ├── schema.py ├── tracker │ ├── schema.py │ ├── model.py │ └── router.py ├── models.py ├── alembic.ini ├── main.py └── Pipfile.lock ├── svelte ├── .gitignore ├── public │ ├── favicon.png │ ├── Dockerfile │ ├── index.html │ └── global.css ├── src │ ├── main.js │ └── App.svelte ├── Dockerfile ├── package.json ├── rollup.config.js ├── README.md └── scripts │ └── setupTypeScript.js ├── pgadmin4 └── .env.template ├── postgres └── .env.template ├── nginx ├── index.html ├── Dockerfile ├── auth │ └── README.md └── conf │ └── project.conf ├── dhparam └── dhparam-2048.pem ├── docker-compose.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .htpasswd -------------------------------------------------------------------------------- /streamlit/config.py: -------------------------------------------------------------------------------- 1 | API_URL = 'http://api:8000' -------------------------------------------------------------------------------- /api/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /streamlit/.streamlit/.credentials.IGNORE.toml: -------------------------------------------------------------------------------- 1 | [general] 2 | 3 | email="" -------------------------------------------------------------------------------- /svelte/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /public/build/ 3 | 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /svelte/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sapped/flip/HEAD/svelte/public/favicon.png -------------------------------------------------------------------------------- /pgadmin4/.env.template: -------------------------------------------------------------------------------- 1 | PGADMIN_DEFAULT_EMAIL=pgadmin4@pgadmin.org 2 | PGADMIN_DEFAULT_PASSWORD=admin -------------------------------------------------------------------------------- /postgres/.env.template: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=postgres 2 | POSTGRES_PASSWORD=postgres 3 | POSTGRES_DB=postgres -------------------------------------------------------------------------------- /nginx/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

My First Heading

6 |

My first paragraph.

7 | 8 | 9 | -------------------------------------------------------------------------------- /streamlit/src/style/stringformats.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | def es_date_format(x): 4 | return dt.datetime.fromtimestamp(x).strftime('%b %e, %Y %r') -------------------------------------------------------------------------------- /api/.env.template: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=postgres 2 | POSTGRES_PASSWORD=postgres 3 | POSTGRES_DB=postgres 4 | 5 | DATABASE_URL = postgresql+psycopg2://{user}:{password}@{host}:{port} -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:mainline-alpine 2 | 3 | WORKDIR /nginx 4 | COPY . /nginx 5 | 6 | COPY /conf/project.conf /etc/nginx/conf.d/ 7 | RUN rm /etc/nginx/conf.d/default.conf -------------------------------------------------------------------------------- /svelte/public/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | WORKDIR /svelte 3 | 4 | COPY rollup.config.js ./ 5 | COPY package*.json ./ 6 | RUN npm i -g npm 7 | RUN npm install 8 | 9 | COPY . . -------------------------------------------------------------------------------- /svelte/src/main.js: -------------------------------------------------------------------------------- 1 | import App from './App.svelte'; 2 | 3 | const app = new App({ 4 | target: document.body, 5 | props: { 6 | name: 'world' 7 | } 8 | }); 9 | 10 | export default app; -------------------------------------------------------------------------------- /svelte/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | WORKDIR /svelte 3 | 4 | 5 | # COPY . /svelte 6 | 7 | COPY rollup.config.js ./ 8 | COPY package*.json ./ 9 | RUN npm i -g npm 10 | RUN npm install 11 | 12 | COPY . . -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | 3 | ENV PYTHONDONTWRITEBYTECODE 1 4 | ENV PYTHONUNBUFFERED 1 5 | 6 | WORKDIR /api/ 7 | 8 | RUN pip install pipenv 9 | COPY Pipfile Pipfile.lock /api/ 10 | RUN pipenv install --system --dev 11 | 12 | COPY . /api/ -------------------------------------------------------------------------------- /api/user/schema.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from pydantic import BaseModel 3 | 4 | class User(BaseModel): 5 | id: Optional[int] 6 | username: str 7 | created_at: Optional[float] 8 | 9 | class Config: 10 | orm_mode=True -------------------------------------------------------------------------------- /streamlit/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | streamlit = "*" 8 | pandas = "*" 9 | awesome-streamlit = "*" 10 | plotly = "*" 11 | plotly-express = "*" 12 | 13 | [dev-packages] 14 | 15 | [requires] 16 | python_version = "3.8" 17 | -------------------------------------------------------------------------------- /api/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | uvicorn = "*" 8 | fastapi = "*" 9 | fastapi-sqlalchemy = "*" 10 | pydantic = "*" 11 | alembic = "*" 12 | python-dotenv = "*" 13 | psycopg2-binary = "*" 14 | 15 | [dev-packages] 16 | 17 | [requires] 18 | python_version = "3.8" 19 | -------------------------------------------------------------------------------- /streamlit/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | 3 | WORKDIR /streamlit 4 | ENV PYTHONDONTWRITEBYTECODE 1 5 | ENV PYTHONBUFFERED 1 6 | 7 | COPY . /streamlit 8 | 9 | # Intentionally do not expose port 8501 or else people can circumvent login 10 | 11 | RUN pip install pipenv 12 | COPY Pipfile Pipfile.lock /streamlit/ 13 | RUN pipenv install --system --dev 14 | 15 | CMD streamlit run app.py -------------------------------------------------------------------------------- /api/schema.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from pydantic import BaseModel 3 | 4 | class Goal(BaseModel): 5 | goal: str 6 | has_amount: bool 7 | date_created: float 8 | 9 | class Config: 10 | orm_mode = True 11 | 12 | class Entry(BaseModel): 13 | goal_id: int 14 | tracked: bool 15 | amount: Optional[float] 16 | date: Optional[float] 17 | 18 | class Config: 19 | orm_mode = True -------------------------------------------------------------------------------- /api/user/model.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declarative_base 2 | from sqlalchemy import Column, Integer, ForeignKey, String, Float, Boolean 3 | from sqlalchemy.orm import relationship 4 | 5 | Base = declarative_base() 6 | 7 | class User(Base): 8 | __tablename__ = 'user' 9 | id = Column(Integer, primary_key=True, index=True) 10 | username = Column(String, nullable=False) 11 | created_at = Column(Float, nullable=False) -------------------------------------------------------------------------------- /dhparam/dhparam-2048.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN DH PARAMETERS----- 2 | MIIBCAKCAQEA4gKiHvyveN1v90rUvreuRRYjjxrzqZVQuAsvKo/y3BdsmFLWCKww 3 | 0X5KjCrzYWgOf23H5eqeH8fb9XFkAxOYnR9ITZGPxJg5ylJTfAG1DxdEZOlGcZU2 4 | ewgMjQAzAZmWFTA7T4c6qet+DcxZC3Vw0UpYyfyu7a2cvsBeuEWjai/Ho9yzs26J 5 | VFyAy/QRRcimvEaNaQv1FYNhXwNqamdGzwtHnvD295Cyk+06RPS0FxR71HJMTKeC 6 | WTdcPo+OH5qBWeTe6CxZ0lOAoDZJjV/Cm7d/GkyLias+Dz60GLlAo2WMycjKRshD 7 | GWLTJCHfn/TYQyLQYNKJ8EXqmyaQbIsBgwIBAg== 8 | -----END DH PARAMETERS----- 9 | -------------------------------------------------------------------------------- /streamlit/src/pages/misc.py: -------------------------------------------------------------------------------- 1 | # stdlib imports 2 | import time 3 | 4 | # library imports 5 | import streamlit as st 6 | 7 | # local imports 8 | from config import API_URL 9 | 10 | PAGE_TITLE = 'Miscellaneous Gags' 11 | 12 | def write(): 13 | st.markdown(f'# {PAGE_TITLE}') 14 | 15 | st.markdown('### Seconds since January 1, 1970') 16 | st.write('Press r to refresh the page') 17 | st.write(time.time()) 18 | 19 | if __name__=='__main__': 20 | write() -------------------------------------------------------------------------------- /svelte/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Svelte app 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /nginx/auth/README.md: -------------------------------------------------------------------------------- 1 | put your .htpasswd file here & run: 2 | 3 | htpasswd -c .htpasswd 4 | 5 | to add another to an existing file (-c overwrites existing), I run: 6 | 7 | htpasswd -n 8 | 9 | then copy-paste the command-line result into the existing .htpasswd file 10 | 11 | [more here](https://httpd.apache.org/docs/2.4/programs/htpasswd.html) 12 | 13 | 14 | FYI, you may need to install apache2-utils if you don't have it already 15 | $ sudo apt update 16 | $ sudo apt install apache2-utils\ -------------------------------------------------------------------------------- /api/tracker/schema.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from pydantic import BaseModel 3 | 4 | class Entry_Type(BaseModel): 5 | entry_type: str 6 | has_description: bool 7 | has_amount: bool 8 | created_at: Optional[float] 9 | user_id: int 10 | 11 | class Config: 12 | orm_mode=True 13 | 14 | class Entry(BaseModel): 15 | id: Optional[int] 16 | entry_type: str 17 | description: Optional[str] 18 | amount: Optional[float] 19 | created_at: Optional[float] 20 | user_id: int 21 | 22 | class Config: 23 | orm_mode=True -------------------------------------------------------------------------------- /svelte/src/App.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |

Hello {name}!

7 |

Visit the Svelte tutorial to learn how to build Svelte apps.

8 |
9 | 10 | -------------------------------------------------------------------------------- /api/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /api/alembic/versions/a5ee42f3cea7_created_user_model.py: -------------------------------------------------------------------------------- 1 | """created user model 2 | 3 | Revision ID: a5ee42f3cea7 4 | Revises: e74329689b23 5 | Create Date: 2021-01-09 21:27:26.887222 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'a5ee42f3cea7' 14 | down_revision = 'e74329689b23' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | pass 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | pass 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /api/alembic/versions/aaa8141c13b6_import_user_model_metadata_into_alembic.py: -------------------------------------------------------------------------------- 1 | """import user model metadata into alembic 2 | 3 | Revision ID: aaa8141c13b6 4 | Revises: 09d5d3f71cf4 5 | Create Date: 2021-01-09 21:31:53.065966 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'aaa8141c13b6' 14 | down_revision = '09d5d3f71cf4' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | pass 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | pass 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /api/alembic/versions/e9ac74c2fb4f_passing_user_model_thru_user_.py: -------------------------------------------------------------------------------- 1 | """passing User model thru user relationship) 2 | 3 | 4 | Revision ID: e9ac74c2fb4f 5 | Revises: 2171019a778e 6 | Create Date: 2021-01-09 22:57:55.812261 7 | 8 | """ 9 | from alembic import op 10 | import sqlalchemy as sa 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = 'e9ac74c2fb4f' 15 | down_revision = '2171019a778e' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | pass 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | pass 29 | # ### end Alembic commands ### 30 | -------------------------------------------------------------------------------- /api/alembic/versions/09d5d3f71cf4_created_user_model_but_imported_into_.py: -------------------------------------------------------------------------------- 1 | """created user model but imported into main models.py this time 2 | 3 | Revision ID: 09d5d3f71cf4 4 | Revises: a5ee42f3cea7 5 | Create Date: 2021-01-09 21:29:04.659030 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '09d5d3f71cf4' 14 | down_revision = 'a5ee42f3cea7' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | pass 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | pass 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /api/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declarative_base 2 | from sqlalchemy import Column, Integer, ForeignKey, String, Float, Boolean 3 | 4 | Base = declarative_base() 5 | 6 | class Goal(Base): 7 | __tablename__ = 'goals' 8 | id = Column(Integer, primary_key=True, index=True) 9 | goal = Column(String, nullable=False, unique=True) 10 | has_amount = Column(Boolean, nullable=False) 11 | date_created = Column(Float, nullable=False) 12 | 13 | class Entry(Base): 14 | __tablename__ = 'entries' 15 | 16 | id = Column(Integer, primary_key=True, index=True) 17 | goal_id = Column(Integer, ForeignKey('goals.id'), nullable=False) 18 | date = Column(Float, nullable=False) 19 | tracked = Column(Boolean, nullable=False) 20 | amount = Column(Float, nullable=True) -------------------------------------------------------------------------------- /svelte/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-app", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "rollup -c", 7 | "dev": "rollup -c -w", 8 | "start": "sirv public --host -s" 9 | }, 10 | "devDependencies": { 11 | "@rollup/plugin-commonjs": "^17.0.0", 12 | "@rollup/plugin-node-resolve": "^11.0.0", 13 | "rollup": "^2.3.4", 14 | "rollup-plugin-css-only": "^3.1.0", 15 | "rollup-plugin-livereload": "^2.0.0", 16 | "rollup-plugin-svelte": "^7.0.0", 17 | "rollup-plugin-terser": "^7.0.0", 18 | "svelte": "^3.0.0" 19 | }, 20 | "dependencies": { 21 | "@rollup/plugin-replace": "^2.4.1", 22 | "@urql/svelte": "^1.2.0", 23 | "graphql": "^15.5.0", 24 | "sirv-cli": "^1.0.0", 25 | "svelte-routing": "^1.5.0", 26 | "svelte-typewriter": "^2.4.5" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /streamlit/user/user.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | import streamlit as st 4 | 5 | from sessionstate import _get_full_session 6 | from config import API_URL 7 | 8 | class User(): 9 | def __init__(self): 10 | self.resource = f'{API_URL}/user' 11 | self.current_username = self.get_active_user() 12 | self.db_user = self.get_db_user() 13 | 14 | 15 | def get_db_user(self): 16 | res = requests.get(f'{self.resource}/{self.current_username}') 17 | return res.json() 18 | f 19 | def get_active_user(self): 20 | session = _get_full_session() 21 | try: 22 | user = session.ws.request.headers['X-Forwarded-User'] 23 | except KeyError: 24 | user = 'ed' 25 | # user_list = requests.get(f'{self.resource}') 26 | # st.write(user_list) 27 | # user = st.selectbox(options=user_list['name']) 28 | 29 | return user -------------------------------------------------------------------------------- /api/user/router.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import List 3 | 4 | from fastapi import APIRouter, HTTPException 5 | from fastapi_sqlalchemy import db 6 | 7 | from . import model 8 | from . import schema 9 | 10 | router = APIRouter( 11 | prefix = '/user', 12 | tags = ['User'] 13 | ) 14 | 15 | @router.get('/', response_model=List[schema.User]) 16 | def get_users(): 17 | db_users = db.session.query(model.User).all() 18 | return db_users 19 | 20 | @router.get('/{username}', response_model=schema.User) 21 | def get_or_create_user(username: str): 22 | db_user = db.session.query(model.User).filter(model.User.username==username).one_or_none() 23 | # user doesn't exist, create (users managed by htpasswd) 24 | if db_user is None: 25 | db_user = model.User( 26 | username = user.username, 27 | created_at = time.time() 28 | ) 29 | db.session.add(db_user) 30 | db.session.commit() 31 | 32 | return db_user -------------------------------------------------------------------------------- /streamlit/src/style/charts.py: -------------------------------------------------------------------------------- 1 | # library imports 2 | import plotly.express as px 3 | import pandas as pd 4 | import streamlit as st 5 | 6 | def table_cols(df_in): 7 | df = df_in.reset_index() 8 | st.table(df) 9 | return 0 10 | 11 | # TBU - row-wise containers necessary to implement this 12 | rows, cols = df.shape[0], df.shape[1] 13 | col_headers = st.beta_columns(cols) 14 | col_placeholders = st.beta_columns(cols) 15 | 16 | for colnum, col in enumerate(df.columns): 17 | with col_headers[colnum]: 18 | st.write(col) 19 | 20 | st.markdown('***') 21 | 22 | for colnum, col in enumerate(df.columns): 23 | with col_placeholders[colnum]: 24 | for row in df[col]: 25 | st.write(row) 26 | 27 | 28 | 29 | 30 | 31 | 32 | return 0 33 | 34 | def line_chart(df): 35 | fig = px.line(df, x='date', y='weight', title='Weight') 36 | fig.show() 37 | 38 | def heatmap(df): 39 | df.style.apply(lambda x: ['background: green' if x > 1 else '']) 40 | return df 41 | -------------------------------------------------------------------------------- /api/tracker/model.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declarative_base 2 | from sqlalchemy import Column, Integer, ForeignKey, String, Float, Boolean 3 | from sqlalchemy.orm import relationship 4 | 5 | from user.model import User 6 | 7 | Base = declarative_base() 8 | 9 | class Entry_Type(Base): 10 | __tablename__='entry_type' 11 | entry_type = Column(String, primary_key=True, index=True) 12 | has_description = Column(Boolean, nullable=False) 13 | has_amount = Column(Boolean, nullable=False) 14 | created_at = Column(Float, nullable=False) 15 | user_id = Column(Integer, ForeignKey(User.id)) 16 | 17 | # backref: https://docs.sqlalchemy.org/en/13/orm/basic_relationships.html 18 | class Entry(Base): 19 | __tablename__='entry' 20 | id = Column(Integer, primary_key=True, index=True) 21 | description = Column(String) 22 | amount = Column(Float) 23 | created_at = Column(Float, nullable=False) 24 | entry_type = Column(String, ForeignKey('entry_type.entry_type')) 25 | user_id = Column(Integer, ForeignKey(User.id)) 26 | user = relationship(User,backref='entries') -------------------------------------------------------------------------------- /streamlit/app.py: -------------------------------------------------------------------------------- 1 | # stdlib imports 2 | 3 | # library imports 4 | import streamlit as st 5 | import pandas as pd 6 | import awesome_streamlit as ast # https://github.com/MarcSkovMadsen/awesome-streamlit 7 | 8 | # local imports 9 | import src.pages.new_tracker 10 | import src.pages.goals 11 | import src.pages.entries 12 | import src.pages.misc 13 | from user.user import User 14 | 15 | st.set_page_config( 16 | initial_sidebar_state="collapsed", 17 | page_title='Flip that rip') 18 | 19 | PAGES = { 20 | 'Goal Tracker v2': src.pages.new_tracker, 21 | 'Track Performance': src.pages.entries, 22 | 'Manage Goals': src.pages.goals, 23 | 'Miscellaneous Gags': src.pages.misc, 24 | } 25 | 26 | def main(): 27 | user = User() 28 | st.write(user) 29 | st.sidebar.title("Navigation") 30 | selection = st.sidebar.radio("Go to", list(PAGES.keys())) 31 | st.sidebar.markdown(f'Logged in as **{user.current_username}**.') 32 | 33 | page = PAGES[selection] 34 | 35 | with st.spinner(f"Loading {selection} ..."): 36 | ast.shared.components.write_page(page) 37 | 38 | if __name__=='__main__': 39 | main() -------------------------------------------------------------------------------- /api/alembic/versions/8d31408b2f13_fixed_metadata_bug_in_alembic_env_py_.py: -------------------------------------------------------------------------------- 1 | """fixed metadata bug in alembic env.py target_metadata imports 2 | 3 | Revision ID: 8d31408b2f13 4 | Revises: aaa8141c13b6 5 | Create Date: 2021-01-09 21:43:22.586666 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '8d31408b2f13' 14 | down_revision = 'aaa8141c13b6' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('user', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('username', sa.String(), nullable=False), 24 | sa.Column('created_at', sa.Float(), nullable=False), 25 | sa.PrimaryKeyConstraint('id') 26 | ) 27 | op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_index(op.f('ix_user_id'), table_name='user') 34 | op.drop_table('user') 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /svelte/public/global.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | body { 8 | color: #333; 9 | margin: 0; 10 | padding: 8px; 11 | box-sizing: border-box; 12 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 13 | } 14 | 15 | a { 16 | color: rgb(0,100,200); 17 | text-decoration: none; 18 | } 19 | 20 | a:hover { 21 | text-decoration: underline; 22 | } 23 | 24 | a:visited { 25 | color: rgb(0,80,160); 26 | } 27 | 28 | label { 29 | display: block; 30 | } 31 | 32 | input, button, select, textarea { 33 | font-family: inherit; 34 | font-size: inherit; 35 | -webkit-padding: 0.4em 0; 36 | padding: 0.4em; 37 | margin: 0 0 0.5em 0; 38 | box-sizing: border-box; 39 | border: 1px solid #ccc; 40 | border-radius: 2px; 41 | } 42 | 43 | input:disabled { 44 | color: #ccc; 45 | } 46 | 47 | button { 48 | color: #333; 49 | background-color: #f4f4f4; 50 | outline: none; 51 | } 52 | 53 | button:disabled { 54 | color: #999; 55 | } 56 | 57 | button:not(:disabled):active { 58 | background-color: #ddd; 59 | } 60 | 61 | button:focus { 62 | border-color: #666; 63 | } 64 | -------------------------------------------------------------------------------- /api/tracker/router.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import List 3 | 4 | from fastapi import APIRouter, HTTPException 5 | from fastapi_sqlalchemy import db 6 | 7 | from . import model 8 | from . import schema 9 | 10 | router = APIRouter( 11 | prefix='/tracker', 12 | tags=['Tracker'] 13 | ) 14 | 15 | @router.get('/entry_type', response_model=List[schema.Entry_Type]) 16 | def get_entry_types(): 17 | db_entry_types = db.session.query(model.Entry_Type).all() 18 | return db_entry_types 19 | 20 | @router.get('/entry', response_model=List[schema.Entry]) 21 | def get_entry(): 22 | db_entry = db.session.query(model.Entry).all() 23 | return db_entry 24 | 25 | @router.post('/entry_type', response_model=schema.Entry_Type) 26 | def create_entry_type(entry_type: schema.Entry_Type): 27 | db_entry_type = model.Entry_Type( 28 | entry_type = entry_type.entry_type, 29 | has_description = entry_type.has_description, 30 | has_amount = entry_type.has_amount, 31 | created_at = time.time(), 32 | user_id = entry_type.user_id, 33 | ) 34 | db.session.add(db_entry_type) 35 | db.session.commit() 36 | 37 | return db_entry_type 38 | 39 | @router.post('/entry', response_model=schema.Entry) 40 | def create_entry(entry: schema.Entry): 41 | db_entry = model.Entry( 42 | description = entry.description, 43 | amount = entry.amount, 44 | created_at = time.time(), 45 | entry_type = entry.entry_type, 46 | user_id = entry.user_id, 47 | ) 48 | db.session.add(db_entry) 49 | db.session.commit() 50 | 51 | return db_entry -------------------------------------------------------------------------------- /api/alembic/versions/e74329689b23_first_commit.py: -------------------------------------------------------------------------------- 1 | """first commit 2 | 3 | Revision ID: e74329689b23 4 | Revises: 5 | Create Date: 2021-01-03 16:12:23.163633 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'e74329689b23' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('goals', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('goal', sa.String(), nullable=False), 24 | sa.Column('has_amount', sa.Boolean(), nullable=False), 25 | sa.Column('date_created', sa.Float(), nullable=False), 26 | sa.PrimaryKeyConstraint('id'), 27 | sa.UniqueConstraint('goal') 28 | ) 29 | op.create_index(op.f('ix_goals_id'), 'goals', ['id'], unique=False) 30 | op.create_table('entries', 31 | sa.Column('id', sa.Integer(), nullable=False), 32 | sa.Column('goal_id', sa.Integer(), nullable=False), 33 | sa.Column('date', sa.Float(), nullable=False), 34 | sa.Column('tracked', sa.Boolean(), nullable=False), 35 | sa.Column('amount', sa.Float(), nullable=True), 36 | sa.ForeignKeyConstraint(['goal_id'], ['goals.id'], ), 37 | sa.PrimaryKeyConstraint('id') 38 | ) 39 | op.create_index(op.f('ix_entries_id'), 'entries', ['id'], unique=False) 40 | # ### end Alembic commands ### 41 | 42 | 43 | def downgrade(): 44 | # ### commands auto generated by Alembic - please adjust! ### 45 | op.drop_index(op.f('ix_entries_id'), table_name='entries') 46 | op.drop_table('entries') 47 | op.drop_index(op.f('ix_goals_id'), table_name='goals') 48 | op.drop_table('goals') 49 | # ### end Alembic commands ### 50 | -------------------------------------------------------------------------------- /streamlit/tracker/tracker.py: -------------------------------------------------------------------------------- 1 | import time 2 | import requests 3 | from datetime import datetime 4 | 5 | import pandas as pd 6 | 7 | from sessionstate import _get_full_session 8 | from config import API_URL 9 | 10 | class Tracker(): 11 | def __init__(self, hours_back=24): 12 | self.resource = f'{API_URL}/tracker' 13 | self.existing_types = self.existing_types() 14 | self.existing_entries = self.existing_entries(hours_back) 15 | 16 | def existing_entries(self,hours_back): 17 | res = requests.get(f'{self.resource}/entry') 18 | cutoff_time = time.time() 19 | cutoff_time -= 24*60*60 20 | LD = res.json() 21 | if LD == []: 22 | return pd.DataFrame() 23 | else: 24 | v = {k: [dic[k] for dic in LD] for k in LD[0]} 25 | df = pd.DataFrame(v) 26 | df = df[df['created_at']>=cutoff_time] 27 | df['created_at'] = df['created_at'].apply(datetime.fromtimestamp) 28 | return df.set_index('id') 29 | 30 | def existing_types(self): 31 | res = requests.get(f'{self.resource}/entry_type') 32 | LD = res.json() 33 | if LD == []: 34 | return pd.DataFrame() 35 | else: 36 | v = {k: [dic[k] for dic in LD] for k in LD[0]} 37 | df = pd.DataFrame(v) 38 | return df.set_index('entry_type') 39 | 40 | def create_entry(self, json): 41 | res = requests.post(f'{self.resource}/entry', json=json) 42 | LD = res.json() 43 | return LD 44 | 45 | def create_type(self, entry_type, has_description, has_amount, user_id): 46 | json = { 47 | 'entry_type': entry_type, 48 | 'has_description': has_description, 49 | 'has_amount': has_amount, 50 | 'user_id': user_id, 51 | } 52 | res = requests.post(f'{self.resource}/entry_type', json=json) 53 | return res.json() 54 | 55 | def _get_active_user(): 56 | session = _get_full_session() 57 | user = session.ws.request.headers['X-Forwarded-User'] 58 | return user -------------------------------------------------------------------------------- /api/alembic/versions/2171019a778e_created_entries_db_model.py: -------------------------------------------------------------------------------- 1 | """created entries db model 2 | 3 | Revision ID: 2171019a778e 4 | Revises: 8d31408b2f13 5 | Create Date: 2021-01-09 22:36:26.851407 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '2171019a778e' 14 | down_revision = '8d31408b2f13' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('entry_type', 22 | sa.Column('entry_type', sa.String(), nullable=False), 23 | sa.Column('has_description', sa.Boolean(), nullable=False), 24 | sa.Column('has_amount', sa.Boolean(), nullable=False), 25 | sa.Column('created_at', sa.Float(), nullable=False), 26 | sa.Column('user_id', sa.Integer(), nullable=True), 27 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), 28 | sa.PrimaryKeyConstraint('entry_type') 29 | ) 30 | op.create_index(op.f('ix_entry_type_entry_type'), 'entry_type', ['entry_type'], unique=False) 31 | op.create_table('entry', 32 | sa.Column('id', sa.Integer(), nullable=False), 33 | sa.Column('description', sa.String(), nullable=True), 34 | sa.Column('amount', sa.Float(), nullable=True), 35 | sa.Column('created_at', sa.Float(), nullable=False), 36 | sa.Column('entry_type', sa.String(), nullable=True), 37 | sa.Column('user_id', sa.Integer(), nullable=True), 38 | sa.ForeignKeyConstraint(['entry_type'], ['entry_type.entry_type'], ), 39 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), 40 | sa.PrimaryKeyConstraint('id') 41 | ) 42 | op.create_index(op.f('ix_entry_id'), 'entry', ['id'], unique=False) 43 | # ### end Alembic commands ### 44 | 45 | 46 | def downgrade(): 47 | # ### commands auto generated by Alembic - please adjust! ### 48 | op.drop_index(op.f('ix_entry_id'), table_name='entry') 49 | op.drop_table('entry') 50 | op.drop_index(op.f('ix_entry_type_entry_type'), table_name='entry_type') 51 | op.drop_table('entry_type') 52 | # ### end Alembic commands ### 53 | -------------------------------------------------------------------------------- /svelte/rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from 'rollup-plugin-svelte'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | import livereload from 'rollup-plugin-livereload'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import css from 'rollup-plugin-css-only'; 7 | 8 | const production = !process.env.ROLLUP_WATCH; 9 | 10 | function serve() { 11 | let server; 12 | 13 | function toExit() { 14 | if (server) server.kill(0); 15 | } 16 | 17 | return { 18 | writeBundle() { 19 | if (server) return; 20 | server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], { 21 | stdio: ['ignore', 'inherit', 'inherit'], 22 | shell: true 23 | }); 24 | 25 | process.on('SIGTERM', toExit); 26 | process.on('exit', toExit); 27 | } 28 | }; 29 | } 30 | 31 | export default { 32 | input: 'src/main.js', 33 | output: { 34 | sourcemap: true, 35 | format: 'iife', 36 | name: 'app', 37 | file: 'public/build/bundle.js' 38 | }, 39 | plugins: [ 40 | svelte({ 41 | compilerOptions: { 42 | // enable run-time checks when not in production 43 | dev: !production 44 | } 45 | }), 46 | // we'll extract any component CSS out into 47 | // a separate file - better for performance 48 | css({ output: 'bundle.css' }), 49 | 50 | // If you have external dependencies installed from 51 | // npm, you'll most likely need these plugins. In 52 | // some cases you'll need additional configuration - 53 | // consult the documentation for details: 54 | // https://github.com/rollup/plugins/tree/master/packages/commonjs 55 | resolve({ 56 | browser: true, 57 | dedupe: ['svelte'] 58 | }), 59 | commonjs(), 60 | 61 | // In dev mode, call `npm run start` once 62 | // the bundle has been generated 63 | !production && serve(), 64 | 65 | // Watch the `public` directory and refresh the 66 | // browser on changes when not in production 67 | !production && livereload('public'), 68 | 69 | // If we're building for production (npm run build 70 | // instead of npm run dev), minify 71 | production && terser() 72 | ], 73 | watch: { 74 | clearScreen: false 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /api/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to alembic/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | sqlalchemy.url = 39 | 40 | 41 | [post_write_hooks] 42 | # post_write_hooks defines scripts or Python functions that are run 43 | # on newly generated revision scripts. See the documentation for further 44 | # detail and examples 45 | 46 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 47 | # hooks=black 48 | # black.type=console_scripts 49 | # black.entrypoint=black 50 | # black.options=-l 79 51 | 52 | # Logging configuration 53 | [loggers] 54 | keys = root,sqlalchemy,alembic 55 | 56 | [handlers] 57 | keys = console 58 | 59 | [formatters] 60 | keys = generic 61 | 62 | [logger_root] 63 | level = WARN 64 | handlers = console 65 | qualname = 66 | 67 | [logger_sqlalchemy] 68 | level = WARN 69 | handlers = 70 | qualname = sqlalchemy.engine 71 | 72 | [logger_alembic] 73 | level = INFO 74 | handlers = 75 | qualname = alembic 76 | 77 | [handler_console] 78 | class = StreamHandler 79 | args = (sys.stderr,) 80 | level = NOTSET 81 | formatter = generic 82 | 83 | [formatter_generic] 84 | format = %(levelname)-5.5s [%(name)s] %(message)s 85 | datefmt = %H:%M:%S 86 | -------------------------------------------------------------------------------- /streamlit/src/pages/goals.py: -------------------------------------------------------------------------------- 1 | # stdlib 2 | import time 3 | 4 | # libraries 5 | import streamlit as st 6 | import pandas as pd 7 | 8 | # local 9 | from src.crud import Goal 10 | from src.style.charts import line_chart, heatmap 11 | from config import API_URL 12 | from src.style.stringformats import es_date_format 13 | 14 | 15 | PAGE_TITLE = 'Manage Goals' 16 | 17 | def write(): 18 | st.markdown(f'# {PAGE_TITLE}') 19 | 20 | # initiate & read goals 21 | goals = Goal() 22 | st.markdown('### List Existing Goals') 23 | 24 | if goals.existing_goals.empty == True: 25 | st.markdown('### Create your first goal!') 26 | st.write('This page will change after refresh (press r)') 27 | create_goal(goals) 28 | footer() 29 | return 30 | 31 | out_existing_goals = goals.existing_goals 32 | out_existing_goals['date_created'] = out_existing_goals['date_created'].apply(es_date_format) 33 | st.write(out_existing_goals) 34 | 35 | # create goal 36 | create_goal(goals) 37 | 38 | # delete goal 39 | delete_goal(goals) 40 | 41 | footer() 42 | 43 | def footer(): 44 | st.write('You can track your entries by \ 45 | clicking on the little arrow in the \ 46 | top left to open navigation, then \ 47 | select the appropriate page.') 48 | 49 | def create_goal(goals): 50 | st.markdown('### Create New Goal') 51 | new_goal = { 52 | 'goal': st.text_input(label='Goal Name'), 53 | 'has_amount': st.checkbox(label='Goal tracks an amount? (weight, calories, etc.)'), 54 | 'date_created': time.time(), 55 | } 56 | 57 | create = st.button(label='Create goal') 58 | 59 | if create: 60 | goal_response = goals.create_goal(new_goal) 61 | if goal_response is not None: 62 | create = False 63 | st.write(goal_response) 64 | 65 | def delete_goal(goals): 66 | st.markdown('### Delete Existing Goal') 67 | delete_id = st.selectbox( 68 | label='Choose goal to delete', 69 | options=goals.existing_goals.index, 70 | format_func=lambda x: goals.existing_goals.loc[x,'goal']) 71 | 72 | delete = st.button(label='Delete goal') 73 | 74 | if delete: 75 | goal_response = goals.delete_goal(delete_id) 76 | if goal_response is not None: 77 | delete = False 78 | st.write(goal_response) 79 | 80 | if __name__=='__main__': 81 | write() -------------------------------------------------------------------------------- /streamlit/src/crud.py: -------------------------------------------------------------------------------- 1 | # stdlib imports 2 | import requests 3 | import json 4 | 5 | # library imports 6 | import streamlit as st 7 | import pandas as pd 8 | 9 | # local imports 10 | from config import API_URL 11 | 12 | class Goal(): 13 | 14 | def __init__(self): 15 | self.resource = f'{API_URL}/goals/' 16 | self.existing_goals = self.read_goals() 17 | 18 | def read_goals(self): 19 | res = requests.get(self.resource) 20 | df = pd.read_json(res.text, convert_dates=False) 21 | try: 22 | df.set_index('id', inplace=True) 23 | return df 24 | except KeyError: 25 | return df 26 | 27 | def create_goal(self, goal): 28 | res = requests.post(self.resource, json=goal) 29 | return json.loads(res.text) 30 | 31 | def delete_goal(self, id): 32 | url = f'{self.resource}delete/{id}' 33 | res = requests.post(url) 34 | return json.loads(res.text) 35 | 36 | class Entry(): 37 | 38 | def __init__(self): 39 | # list of dates, least to most recent 40 | self.resource = f'{API_URL}/entries/' 41 | self.existing_entries = self.read_entries() 42 | 43 | def read_entries(self): 44 | res = requests.get(self.resource) 45 | df = pd.read_json(res.text, convert_dates=False) 46 | try: 47 | df.set_index('id', inplace=True) 48 | return df 49 | except KeyError: 50 | return df 51 | 52 | def create_entry(self, entry): 53 | res = requests.post(self.resource, json=entry) 54 | return json.loads(res.text) 55 | 56 | def delete_entry(self, entries): 57 | res = requests.post(f'{self.resource}delete/', json=entries) 58 | return json.loads(res.text) 59 | 60 | 61 | class Tracker(): 62 | 63 | def read_tracker(): 64 | url = f'{API_URL}/trackers/' 65 | res = requests.get(url) 66 | df = pd.read_json(res.text) 67 | return df.set_index('id') 68 | 69 | def submit_tracker(tracker): 70 | url = f'{API_URL}/tracker/' 71 | res = requests.post(url, json=tracker) 72 | return json.loads(res.text) 73 | 74 | def delete_tracker(id): 75 | url = f'{API_URL}/tracker/delete/{id}' 76 | res = requests.post(url) 77 | return json.loads(res.text) 78 | 79 | def update_tracker(tracker, id): 80 | url = f'{API_URL}/tracker/update/{id}' 81 | res = requests.post(url, json=tracker) 82 | return json.loads(res.text) -------------------------------------------------------------------------------- /streamlit/src/pages/new_tracker.py: -------------------------------------------------------------------------------- 1 | # stdlib imports 2 | 3 | # library imports 4 | import streamlit as st 5 | import pandas as pd 6 | 7 | # local imports 8 | from user.user import User 9 | from tracker.tracker import Tracker 10 | 11 | PAGE_TITLE = 'Goal Tracking v2' 12 | 13 | def write(): 14 | st.markdown(f'# {PAGE_TITLE}') 15 | 16 | # initialize models 17 | user = User() 18 | user_id = user.db_user['id'] 19 | tracker = Tracker() 20 | 21 | # TBU - make hours_back based on goal type (calories 24, workout 7*24) 22 | st.markdown('## 1. Choose Goal & Submit Entry') 23 | choose = submit_entry(tracker, user_id) 24 | 25 | st.markdown('## 2. Review Entries') 26 | df = tracker.existing_entries 27 | df = df[df['entry_type']==choose] 28 | sum = df.sum() 29 | sum['entry_type'] = 'Total' 30 | sum['description'] = '24hr Total' 31 | df = df.append(sum, ignore_index=True) 32 | df.drop(columns=['user_id','created_at'],inplace=True) 33 | st.write(df) 34 | st.markdown('## 3. Create New Entry Type') 35 | create_type(tracker, user_id) 36 | 37 | footer() 38 | 39 | def submit_entry(tracker, user_id): 40 | choose = st.selectbox(label='Select Entry Type', options=tracker.existing_types.index, index=0) 41 | row = tracker.existing_types.loc[choose] 42 | submission = {} 43 | if row['has_description']: 44 | submission['description'] = st.text_input(label='Description') 45 | if row['has_amount']: 46 | submission['amount'] = st.number_input(label='Amount') 47 | submission['entry_type'] = choose 48 | submission['user_id'] = user_id 49 | submit = st.button(label='submit') 50 | 51 | if submit: 52 | # need to get variables before dict, then give to dict 53 | res = tracker.create_entry(submission) 54 | st.write(res) 55 | create = False 56 | 57 | return choose 58 | 59 | 60 | 61 | def footer(): 62 | st.write('Feel free to delete or create new goals! \ 63 | Click on the little arrow in the top left \ 64 | to open navigation, then \ 65 | select the appropriate page.') 66 | 67 | def create_type(tracker, user_id): 68 | 69 | type_input = { 70 | 'entry_type': st.text_input(label='Entry Type'), 71 | 'has_description': st.checkbox(label='Has Description?'), 72 | 'has_amount': st.checkbox(label='Has Amount?'), 73 | } 74 | 75 | create = st.button(label='create') 76 | 77 | if create: 78 | 79 | # need to get variables before dict, then give to dict 80 | res = tracker.create_type( 81 | entry_type=type_input['entry_type'], 82 | has_description=type_input['has_description'], 83 | has_amount=type_input['has_amount'], 84 | user_id=user_id, 85 | ) 86 | st.write(res) 87 | create = False 88 | 89 | if __name__=='__main__': 90 | write() -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | # streamlit: 5 | # volumes: 6 | # - ./streamlit/:/streamlit/ 7 | # build: 8 | # context: ./streamlit 9 | # dockerfile: Dockerfile 10 | # container_name: streamlit 11 | # restart: always 12 | # # do not publicly expose port 8501 13 | # # ports: 14 | # # - "8501:8501" 15 | # depends_on: 16 | # - api 17 | # networks: 18 | # - app-network 19 | 20 | certbot: 21 | image: certbot/certbot 22 | container_name: certbot 23 | volumes: 24 | - certbot-etc:/etc/letsencrypt 25 | - certbot-var:/var/lib/letsencrypt 26 | - web-root:/var/www/html 27 | depends_on: 28 | - reverse 29 | command: certonly --webroot --webroot-path=/var/www/html --email edwardsapp@gmail.com --agree-tos --no-eff-email -d flip.rip -d www.flip.rip 30 | 31 | reverse: 32 | container_name: reverse 33 | hostname: reverse 34 | restart: always 35 | build: ./nginx 36 | ports: 37 | - "80:80" 38 | - "443:443" 39 | depends_on: 40 | - pgadmin4 41 | - svelte 42 | # - streamlit 43 | - postgres 44 | - api 45 | volumes: 46 | - certbot-etc:/etc/letsencrypt 47 | - certbot-var:/var/lib/letsencrypt 48 | - web-root:/var/www/html 49 | - dhparam:/etc/ssl/certs 50 | networks: 51 | - app-network 52 | 53 | postgres: 54 | image: postgres:12 55 | volumes: 56 | - postgres_data:/var/lib/postgresql/data/ 57 | # ports: 58 | # - "5432:5432" 59 | env_file: 60 | - ./postgres/.env 61 | networks: 62 | - app-network 63 | 64 | api: 65 | build: 66 | context: ./api 67 | dockerfile: Dockerfile 68 | volumes: 69 | - ./api/:/api/ 70 | command: bash -c "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 --reload" 71 | # ports: 72 | # - "8000:8000" 73 | depends_on: 74 | - postgres 75 | networks: 76 | - app-network 77 | 78 | pgadmin4: 79 | container_name: pgadmin4 80 | image: dpage/pgadmin4 81 | env_file: 82 | - ./pgadmin4/.env 83 | # ports: 84 | # - "5050:80" 85 | depends_on: 86 | - postgres 87 | logging: 88 | driver: none 89 | networks: 90 | - app-network 91 | 92 | svelte: 93 | build: ./svelte 94 | command: npm run dev 95 | volumes: 96 | - ./svelte/:/svelte/ 97 | networks: 98 | - app-network 99 | 100 | volumes: 101 | postgres_data: 102 | certbot-etc: 103 | certbot-var: 104 | web-root: 105 | driver: local 106 | driver_opts: 107 | type: none 108 | device: /home/ed/flip/svelte/public 109 | o: bind 110 | dhparam: 111 | driver: local 112 | driver_opts: 113 | type: none 114 | device: /home/ed/flip/dhparam/ 115 | o: bind 116 | 117 | networks: 118 | app-network: 119 | driver: bridge -------------------------------------------------------------------------------- /api/main.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import uvicorn 4 | from fastapi import FastAPI 5 | from fastapi.openapi.utils import get_openapi 6 | 7 | import os 8 | import datetime as dt 9 | from fastapi_sqlalchemy import DBSessionMiddleware 10 | from fastapi_sqlalchemy import db 11 | from dotenv import load_dotenv 12 | 13 | import models 14 | import schema 15 | 16 | from user import router as user_router 17 | from tracker import router as tracker_router 18 | 19 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 20 | load_dotenv(os.path.join(BASE_DIR,'.env')) 21 | 22 | app = FastAPI( 23 | openapi_url="/api/v1/openapi.json", 24 | root_path='/api/v1' 25 | ) 26 | 27 | app.add_middleware( 28 | DBSessionMiddleware, 29 | db_url=os.environ['DATABASE_URL']) 30 | 31 | app.include_router(user_router.router) 32 | app.include_router(tracker_router.router) 33 | 34 | 35 | # ---------- MANAGE GOALS ---------- # 36 | 37 | # CREATE new goal 38 | @app.post('/goals/', response_model=schema.Goal) 39 | def create_goal(goal: schema.Goal): 40 | 41 | db_goal = models.Goal( 42 | goal = goal.goal, 43 | has_amount = goal.has_amount, 44 | date_created = goal.date_created, 45 | ) 46 | db.session.add(db_goal) 47 | db.session.commit() 48 | return db_goal 49 | 50 | # READ all goals 51 | @app.get('/goals/') 52 | def read_goals(): 53 | db_goals = db.session.query(models.Goal).all() 54 | return db_goals 55 | 56 | # UPDATE na 57 | 58 | # DELETE a goal 59 | @app.post("/goals/delete/{id}", response_model=schema.Goal) 60 | def delete_goal(id: int): 61 | db_goal = db.session.query(models.Goal).filter(models.Goal.id == id).first() 62 | db.session.delete(db_goal) 63 | db.session.commit() 64 | return db_goal 65 | 66 | # --------- MANAGE ENTRIES --------- # 67 | 68 | # CREATE an entry 69 | @app.post("/entries/", response_model=List[schema.Entry]) 70 | def create_entry(entries: List[schema.Entry]): 71 | 72 | db_entries = [] 73 | for entry in entries: 74 | db_entry = models.Entry( 75 | goal_id=entry.goal_id, 76 | date=entry.date, 77 | tracked=entry.tracked, 78 | amount=entry.amount,) 79 | db.session.add(db_entry) 80 | db.session.commit() 81 | db_entries.append(db_entry) 82 | return db_entries 83 | 84 | # READ entries 85 | @app.get('/entries/') 86 | def get_entries(count: int = 365): 87 | db_entries = db.session.query(models.Entry).limit(count).all() 88 | return db_entries 89 | 90 | # UPDATE na 91 | 92 | # DELETE an existing entry 93 | @app.post("/entries/delete/", response_model=List[schema.Entry]) 94 | def delete_entry(entries: List[int]): 95 | print('-------------------------------') 96 | print(entries) 97 | print('-------------------------------') 98 | 99 | db_entries = [] 100 | for id in entries: 101 | db_entry = db.session.query(models.Entry).filter(models.Entry.id == id).first() 102 | db.session.delete(db_entry) 103 | db.session.commit() 104 | db_entries.append(db_entry) 105 | return db_entries 106 | 107 | if __name__ == "__main__": 108 | uvicorn.run(app, host="127.0.0.1", port=8000) -------------------------------------------------------------------------------- /svelte/README.md: -------------------------------------------------------------------------------- 1 | *Looking for a shareable component template? Go here --> [sveltejs/component-template](https://github.com/sveltejs/component-template)* 2 | 3 | --- 4 | 5 | # svelte app 6 | 7 | This is a project template for [Svelte](https://svelte.dev) apps. It lives at https://github.com/sveltejs/template. 8 | 9 | To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit): 10 | 11 | ```bash 12 | npx degit sveltejs/template svelte-app 13 | cd svelte-app 14 | ``` 15 | 16 | *Note that you will need to have [Node.js](https://nodejs.org) installed.* 17 | 18 | 19 | ## Get started 20 | 21 | Install the dependencies... 22 | 23 | ```bash 24 | cd svelte-app 25 | npm install 26 | ``` 27 | 28 | ...then start [Rollup](https://rollupjs.org): 29 | 30 | ```bash 31 | npm run dev 32 | ``` 33 | 34 | Navigate to [localhost:5000](http://localhost:5000). You should see your app running. Edit a component file in `src`, save it, and reload the page to see your changes. 35 | 36 | By default, the server will only respond to requests from localhost. To allow connections from other computers, edit the `sirv` commands in package.json to include the option `--host 0.0.0.0`. 37 | 38 | If you're using [Visual Studio Code](https://code.visualstudio.com/) we recommend installing the official extension [Svelte for VS Code](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). If you are using other editors you may need to install a plugin in order to get syntax highlighting and intellisense. 39 | 40 | ## Building and running in production mode 41 | 42 | To create an optimised version of the app: 43 | 44 | ```bash 45 | npm run build 46 | ``` 47 | 48 | You can run the newly built app with `npm run start`. This uses [sirv](https://github.com/lukeed/sirv), which is included in your package.json's `dependencies` so that the app will work when you deploy to platforms like [Heroku](https://heroku.com). 49 | 50 | 51 | ## Single-page app mode 52 | 53 | By default, sirv will only respond to requests that match files in `public`. This is to maximise compatibility with static fileservers, allowing you to deploy your app anywhere. 54 | 55 | If you're building a single-page app (SPA) with multiple routes, sirv needs to be able to respond to requests for *any* path. You can make it so by editing the `"start"` command in package.json: 56 | 57 | ```js 58 | "start": "sirv public --single" 59 | ``` 60 | 61 | ## Using TypeScript 62 | 63 | This template comes with a script to set up a TypeScript development environment, you can run it immediately after cloning the template with: 64 | 65 | ```bash 66 | node scripts/setupTypeScript.js 67 | ``` 68 | 69 | Or remove the script via: 70 | 71 | ```bash 72 | rm scripts/setupTypeScript.js 73 | ``` 74 | 75 | ## Deploying to the web 76 | 77 | ### With [Vercel](https://vercel.com) 78 | 79 | Install `vercel` if you haven't already: 80 | 81 | ```bash 82 | npm install -g vercel 83 | ``` 84 | 85 | Then, from within your project folder: 86 | 87 | ```bash 88 | cd public 89 | vercel deploy --name my-project 90 | ``` 91 | 92 | ### With [surge](https://surge.sh/) 93 | 94 | Install `surge` if you haven't already: 95 | 96 | ```bash 97 | npm install -g surge 98 | ``` 99 | 100 | Then, from within your project folder: 101 | 102 | ```bash 103 | npm run build 104 | surge public my-project.surge.sh 105 | ``` 106 | -------------------------------------------------------------------------------- /api/alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | 6 | from alembic import context 7 | 8 | # ---------------- added code here -------------------------# 9 | import os, sys 10 | from dotenv import load_dotenv 11 | 12 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 13 | load_dotenv(os.path.join(BASE_DIR, ".env")) 14 | sys.path.append(BASE_DIR) 15 | 16 | #------------------------------------------------------------# 17 | 18 | # this is the Alembic Config object, which provides 19 | # access to the values within the .ini file in use. 20 | config = context.config 21 | 22 | # ---------------- added code here -------------------------# 23 | # this will overwrite the ini-file sqlalchemy.url path 24 | # with the path given in the config of the main code 25 | config.set_main_option('sqlalchemy.url', os.environ['DATABASE_URL']) 26 | 27 | #------------------------------------------------------------# 28 | 29 | # Interpret the config file for Python logging. 30 | # This line sets up loggers basically. 31 | fileConfig(config.config_file_name) 32 | 33 | # add your model's MetaData object here 34 | # for 'autogenerate' support 35 | # from myapp import mymodel 36 | # target_metadata = mymodel.Base.metadata 37 | 38 | # ---------------- added code here -------------------------# 39 | from models import Base as ParentBase 40 | from user.model import Base as UserBase 41 | from tracker.model import Base as TrackerBase 42 | #------------------------------------------------------------# 43 | 44 | 45 | # ---------------- added code here -------------------------# 46 | # was target_metadata = None 47 | target_metadata = [ParentBase.metadata, UserBase.metadata, TrackerBase.metadata] 48 | #------------------------------------------------------------# 49 | 50 | # other values from the config, defined by the needs of env.py, 51 | # can be acquired: 52 | # my_important_option = config.get_main_option("my_important_option") 53 | # ... etc. 54 | 55 | 56 | def run_migrations_offline(): 57 | """Run migrations in 'offline' mode. 58 | 59 | This configures the context with just a URL 60 | and not an Engine, though an Engine is acceptable 61 | here as well. By skipping the Engine creation 62 | we don't even need a DBAPI to be available. 63 | 64 | Calls to context.execute() here emit the given string to the 65 | script output. 66 | 67 | """ 68 | url = config.get_main_option("sqlalchemy.url") 69 | context.configure( 70 | url=url, 71 | target_metadata=target_metadata, 72 | literal_binds=True, 73 | dialect_opts={"paramstyle": "named"}, 74 | ) 75 | 76 | with context.begin_transaction(): 77 | context.run_migrations() 78 | 79 | 80 | def run_migrations_online(): 81 | """Run migrations in 'online' mode. 82 | 83 | In this scenario we need to create an Engine 84 | and associate a connection with the context. 85 | 86 | """ 87 | connectable = engine_from_config( 88 | config.get_section(config.config_ini_section), 89 | prefix="sqlalchemy.", 90 | poolclass=pool.NullPool, 91 | ) 92 | 93 | with connectable.connect() as connection: 94 | context.configure( 95 | connection=connection, target_metadata=target_metadata 96 | ) 97 | 98 | with context.begin_transaction(): 99 | context.run_migrations() 100 | 101 | 102 | if context.is_offline_mode(): 103 | run_migrations_offline() 104 | else: 105 | run_migrations_online() 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Backend Code for personal site, flip.rip 2 | 3 | Full-stack Streamlit implementation, [see this repo](https://github.com/sapped/Authenticated-Full-Stack-Streamlit) for a template you can more quickly rip. This is for my personal website. I predominately use it to track my own goals, but the idea is to put it all here. It will continue to change, but figure I'd share what I make here. 4 | 5 | ## Goals Tracker 6 | Create goals, which have optional amounts. Then track them. Store it in a Postgres DB, manage db with Pgadmin4, and GUI via Streamlit. 7 | 8 | ## Users 9 | Functionality for barebones users. Lacks basics like creation routine, logout, etc. But if you just need ***something***, I hope this does the trick for you. 10 | 11 | Relies upon: 12 | - Docker (internal, non-exposed networking & enable reverse-proxy with NGINX) 13 | - NGINX (auth_basic, $remote_user) 14 | - Implementation of Streamlit Sessionstate (important to avoid cross-talk between sessions, don't want three users logged in on different computers but they're all being served as if they were the same user) 15 | 16 | See sidebar for confirmation 'you are logged in as {user}'. Worth testing on two devices simultaneously with two different users to confirm they are both different, and not the same user. 17 | 18 | ### The magic behind my implementation 19 | 20 | #### Configure reverse proxy, the auth_basic manges the user auth 21 | Probably fancier ways to do this, but auth_basic proves the concept 22 | 23 | 1. edit file ~/main_dir/nginx/conf/project.conf 24 | 2. At every exposed endpoint, add the code I have, anything that starts with 'auth_basic' (as of writing, this is 'auth_basic' and 'auth_basic_user_file') 25 | 3. follow the README.md in ~/main_dir/nginx/auth/README.md to create users. Requires some apache2 tools installed so you can run htpasswd 26 | 4. This is it for configuration of nginx reverse proxy for auth. The magic now is: within our local docker container network, we can speak in HTTP without worrying about HTTPS stifling us ([see forum](https://discuss.streamlit.io/t/user-authentication/612/5?u=eddie)) 27 | 28 | #### Forward User via HTTP 29 | 1. edit file ~/main_dir/nginx/conf/project.conf 30 | 2. go to 'location /stream {}'. Not fully sure what stream does, but I know it passes the HTTP headers to my app 31 | 3. add code "proxy_set_header $remote_user (this shares username)" 32 | 33 | #### Access headers in streamlit 34 | This one was a doozy to figure out! Thanks to all in links referenced below for valuable guidance. 35 | - see code in ~/main_dir/streamlit/user/user.py, which relies on session_state 36 | - also see how the user is grabbed in app.py 37 | - app.py grabs user and displays on sidebar 38 | - you can use from users.users import get_user on whichever page you need users. Or maybe just implement it as part of global context somehow. 39 | - in this build, look at sidebar (if collapsed, click the tiny triangle in the top left of the webpage). Should say at bottom "logged in as **{user}**". 40 | 41 | #### My Opinion on Security 42 | From what I've read in the links below, this is secure. All user communication is handled inside the docker network (internal, inaccessible by outside world). One thing this is missing for a real-life implementation is an SSL certificate. I'll let you handle that on your own - it's a straightforward nginx configuration that you can Google easily. 43 | 44 | But PLEASE let me know if you disagree! Security is essential. I want to ensure this implementation is totally safe and up to modern standards of security. 45 | 46 | ### Links referenced when designing users 47 | - https://discuss.streamlit.io/t/access-request-header-in-streamlit/1882 48 | - https://github.com/streamlit/streamlit/issues/1083 49 | - https://gist.github.com/okld/0aba4869ba6fdc8d49132e6974e2e662 50 | 51 | ## Other helpful things 52 | - dbdiagram.io lets me visualize my ERD (entity relationship diagrams). Aka, those DB tables with connector things. Right now, I think DBML is a cool language for designing databases). 53 | - play around with live dummy at [flip.rip](http://www.flip.rip), creds are "public/public" 54 | 55 | # TBU 56 | - currently no auth_basic on /stream/ endpoint -------------------------------------------------------------------------------- /streamlit/.streamlit/config.IGNORE.toml: -------------------------------------------------------------------------------- 1 | # Below are all the sections and options you can have in ~/.streamlit/config.toml. 2 | 3 | [global] 4 | 5 | # By default, Streamlit checks if the Python watchdog module is available and, if not, prints a warning asking for you to install it. The watchdog module is not required, but highly recommended. It improves Streamlit's ability to detect changes to files in your filesystem. 6 | # If you'd like to turn off this warning, set this to True. 7 | # Default: false 8 | disableWatchdogWarning = false 9 | 10 | # Configure the ability to share apps to the cloud. 11 | # Should be set to one of these values: - "off" : turn off sharing. - "s3" : share to S3, based on the settings under the [s3] section of this config file. 12 | # Default: "off" 13 | sharingMode = "off" 14 | 15 | # If True, will show a warning when you run a Streamlit-enabled script via "python my_script.py". 16 | # Default: true 17 | showWarningOnDirectExecution = true 18 | 19 | 20 | [client] 21 | 22 | # Whether to enable st.cache. 23 | # Default: true 24 | caching = true 25 | 26 | # If false, makes your Streamlit script not draw to a Streamlit app. 27 | # Default: true 28 | displayEnabled = true 29 | 30 | 31 | [runner] 32 | 33 | # Allows you to type a variable or string by itself in a single line of Python code to write it to the app. 34 | # Default: true 35 | magicEnabled = true 36 | 37 | # Install a Python tracer to allow you to stop or pause your script at any point and introspect it. As a side-effect, this slows down your script's execution. 38 | # Default: false 39 | installTracer = false 40 | 41 | # Sets the MPLBACKEND environment variable to Agg inside Streamlit to prevent Python crashing. 42 | # Default: true 43 | fixMatplotlib = true 44 | 45 | 46 | [server] 47 | 48 | # List of folders that should not be watched for changes. Relative paths will be taken as relative to the current working directory. 49 | # Example: ['/home/user1/env', 'relative/path/to/folder'] 50 | # Default: [] 51 | folderWatchBlacklist = [''] 52 | 53 | enableWebsocketCompression = false 54 | 55 | 56 | 57 | # If false, will attempt to open a browser window on start. 58 | # Default: false unless (1) we are on a Linux box where DISPLAY is unset, or (2) server.liveSave is set. 59 | headless = true 60 | 61 | # Immediately share the app in such a way that enables live monitoring, and post-run analysis. 62 | # Default: false 63 | liveSave = false 64 | 65 | # Automatically rerun script when the file is modified on disk. 66 | # Default: false 67 | runOnSave = false 68 | 69 | # The port where the server will listen for client and browser connections. 70 | # Default: 8501 71 | port = 8501 72 | 73 | # Enables support for Cross-Origin Request Sharing, for added security. 74 | # Default: true 75 | enableCORS = false 76 | 77 | 78 | [browser] 79 | 80 | # Internet address of the server server that the browser should connect to. Can be IP address or DNS name. 81 | # Default: 'localhost' 82 | serverAddress = 'streamlit' 83 | 84 | # Whether to send usage statistics to Streamlit. 85 | # Default: true 86 | gatherUsageStats = true 87 | 88 | # Port that the browser should use to connect to the server when in liveSave mode. 89 | # Default: whatever value is set in server.port. 90 | serverPort = 80 91 | 92 | [s3] 93 | 94 | # Name of the AWS S3 bucket to save apps. 95 | # Default: (unset) 96 | #bucket = 97 | 98 | # URL root for external view of Streamlit apps. 99 | # Default: (unset) 100 | #url = 101 | 102 | # Access key to write to the S3 bucket. 103 | # Leave unset if you want to use an AWS profile. 104 | # Default: (unset) 105 | #accessKeyId = 106 | 107 | # Secret access key to write to the S3 bucket. 108 | # Leave unset if you want to use an AWS profile. 109 | # Default: (unset) 110 | #secretAccessKey = 111 | 112 | # The "subdirectory" within the S3 bucket where to save apps. 113 | # S3 calls paths "keys" which is why the keyPrefix is like a subdirectory. Use "" to mean the root directory. 114 | # Default: "" 115 | keyPrefix = "" 116 | 117 | # AWS region where the bucket is located, e.g. "us-west-2". 118 | # Default: (unset) 119 | #region = 120 | 121 | # AWS credentials profile to use. 122 | # Leave unset to use your default profile. 123 | # Default: (unset) 124 | #profile = -------------------------------------------------------------------------------- /svelte/scripts/setupTypeScript.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** This script modifies the project to support TS code in .svelte files like: 4 | 5 | 8 | 9 | As well as validating the code for CI. 10 | */ 11 | 12 | /** To work on this script: 13 | rm -rf test-template template && git clone sveltejs/template test-template && node scripts/setupTypeScript.js test-template 14 | */ 15 | 16 | const fs = require("fs") 17 | const path = require("path") 18 | const { argv } = require("process") 19 | 20 | const projectRoot = argv[2] || path.join(__dirname, "..") 21 | 22 | // Add deps to pkg.json 23 | const packageJSON = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8")) 24 | packageJSON.devDependencies = Object.assign(packageJSON.devDependencies, { 25 | "svelte-check": "^1.0.0", 26 | "svelte-preprocess": "^4.0.0", 27 | "@rollup/plugin-typescript": "^8.0.0", 28 | "typescript": "^4.0.0", 29 | "tslib": "^2.0.0", 30 | "@tsconfig/svelte": "^1.0.0" 31 | }) 32 | 33 | // Add script for checking 34 | packageJSON.scripts = Object.assign(packageJSON.scripts, { 35 | "validate": "svelte-check" 36 | }) 37 | 38 | // Write the package JSON 39 | fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify(packageJSON, null, " ")) 40 | 41 | // mv src/main.js to main.ts - note, we need to edit rollup.config.js for this too 42 | const beforeMainJSPath = path.join(projectRoot, "src", "main.js") 43 | const afterMainTSPath = path.join(projectRoot, "src", "main.ts") 44 | fs.renameSync(beforeMainJSPath, afterMainTSPath) 45 | 46 | // Switch the app.svelte file to use TS 47 | const appSveltePath = path.join(projectRoot, "src", "App.svelte") 48 | let appFile = fs.readFileSync(appSveltePath, "utf8") 49 | appFile = appFile.replace("