+
+ # Assign the Dues-Paying Member role
+ Discord.assign_role(discord_id, Settings().discord.member_role)
+
+ # Send Discord message saying they are a member
+ welcome_msg = f"""Hello {user_data.first_name}, and welcome to Hack@UCF!
+
+This message is to confirm that your membership has processed successfully. You can access and edit your membership ID at https://{Settings().http.domain}/profile.
+
+These credentials can be used to the Hack@UCF Private Cloud, one of our many benefits of paying dues. This can be accessed at {Settings().infra.horizon} while on the CyberLab WiFi.
+
+```yaml
+Username: {creds.get('username', 'Not Set')}
+Password: {creds.get('password', f"Please visit https://{Settings().http.domain}/profile and under Danger Zone, reset your Infra creds.")}
+```
+
+The password for the `Cyberlab` WiFi is currently `{Settings().infra.wifi}`, but this is subject to change (and we'll let you know when that happens).
+
+By using the Hack@UCF Infrastructure, you agree to the following EULA located at https://help.hackucf.org/misc/eula
+
+Happy Hacking,
+ - Hack@UCF Bot
+ """
+
+ Discord.send_message(discord_id, welcome_msg)
+ Email.send_email("Welcome to Hack@UCF", welcome_msg, user_data.email)
+ # Set member as a "full" member.
+ user_data.is_full_member = True
+ session.add(user_data)
+ session.commit()
+ session.refresh(user_data)
+
+ elif user_data.did_pay_dues:
+ logger.info("\tPaid dues but did not do other step!")
+ # Send a message on why this check failed.
+ fail_msg = f"""Hello {user_data.first_name},
+
+We wanted to let you know that you **did not** complete all of the steps for being able to become an Hack@UCF member.
+
+- Provided a name: {'✅' if user_data.first_name else '❌'}
+- Signed Ethics Form: {'✅' if user_data.ethics_form.signtime != 0 else '❌'}
+- Paid $10 dues: ✅
+
+Please complete all of these to become a full member. Once you do, visit https://{Settings().http.domain}/profile to re-run this check.
+
+If you think you have completed all of these, please reach out to an Exec on the Hack@UCF Discord.
+
+We hope to see you soon,
+ - Hack@UCF Bot
+"""
+ Discord.send_message(discord_id, fail_msg)
+
+ else:
+ logger.info("\tDid not pay dues yet.")
+
+ return False
diff --git a/util/authentication.py b/app/util/authentication.py
similarity index 85%
rename from util/authentication.py
rename to app/util/authentication.py
index 60f4dc9..0f5c0f8 100644
--- a/util/authentication.py
+++ b/app/util/authentication.py
@@ -6,9 +6,11 @@
from fastapi.responses import RedirectResponse
from jose import jwt
+from app.models.user import UserModel
+
# Import options and errors
-from util.errors import Errors
-from util.settings import Settings
+from app.util.errors import Errors
+from app.util.settings import Settings
class Authentication:
@@ -73,7 +75,7 @@ async def wrapper_member(
token: Optional[str],
user_jwt: Optional[object],
*args,
- **kwargs
+ **kwargs,
):
# Validate auth.
if not token:
@@ -111,3 +113,21 @@ async def wrapper_member(
return await func(request, token, user_jwt, *args, **kwargs)
return wrapper_member
+
+ def create_jwt(user: UserModel):
+ jwtData = {
+ "discord": user.discord_id,
+ "name": user.discord.username,
+ "pfp": user.discord.avatar,
+ "id": str(user.id),
+ "sudo": user.sudo,
+ "is_full_member": user.is_full_member,
+ "issued": time.time(),
+ "infra_email": user.infra_email,
+ }
+ bearer = jwt.encode(
+ jwtData,
+ Settings().jwt.secret.get_secret_value(),
+ algorithm=Settings().jwt.algorithm,
+ )
+ return bearer
diff --git a/app/util/database.py b/app/util/database.py
new file mode 100644
index 0000000..2b141a0
--- /dev/null
+++ b/app/util/database.py
@@ -0,0 +1,34 @@
+# Create the database
+from alembic import config, script
+from alembic.runtime import migration
+from sqlmodel import Session, SQLModel, create_engine
+from sqlmodel.pool import StaticPool
+
+from app.util.settings import Settings
+
+DATABASE_URL = Settings().database.url
+# TODO remove echo=True
+engine = create_engine(
+ DATABASE_URL,
+ # echo=True,
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+)
+
+
+def init_db():
+ return
+
+
+def get_session():
+ with Session(engine) as session:
+ yield session
+
+
+def check_current_head(alembic_cfg, connectable):
+ # type: (config.Config, engine.Engine) -> bool
+ # cfg = config.Config("../alembic.ini")
+ directory = script.ScriptDirectory.from_config(alembic_cfg)
+ with connectable.begin() as connection:
+ context = migration.MigrationContext.configure(connection)
+ return set(context.get_current_heads()) == set(directory.get_heads())
diff --git a/util/discord.py b/app/util/discord.py
similarity index 97%
rename from util/discord.py
rename to app/util/discord.py
index 3f1e71e..c5e150c 100644
--- a/util/discord.py
+++ b/app/util/discord.py
@@ -2,7 +2,7 @@
import requests
-from util.settings import Settings
+from app.util.settings import Settings
headers = {
"Authorization": f"Bot {Settings().discord.bot_token.get_secret_value()}",
diff --git a/util/email.py b/app/util/email.py
similarity index 96%
rename from util/email.py
rename to app/util/email.py
index 8f36c53..0c57dc6 100644
--- a/util/email.py
+++ b/app/util/email.py
@@ -4,7 +4,7 @@
import commonmark
-from util.settings import Settings
+from app.util.settings import Settings
email = Settings().email.email
password = Settings().email.password.get_secret_value()
diff --git a/util/errors.py b/app/util/errors.py
similarity index 92%
rename from util/errors.py
rename to app/util/errors.py
index e2d14f6..4266d63 100644
--- a/util/errors.py
+++ b/app/util/errors.py
@@ -1,6 +1,6 @@
from fastapi.templating import Jinja2Templates
-templates = Jinja2Templates(directory="templates")
+templates = Jinja2Templates(directory="app/templates")
class Errors:
diff --git a/app/util/forms.py b/app/util/forms.py
new file mode 100644
index 0000000..b4767f6
--- /dev/null
+++ b/app/util/forms.py
@@ -0,0 +1,70 @@
+import json
+import logging
+import os
+from pathlib import Path
+from typing import DefaultDict
+
+logger = logging.getLogger(__name__)
+
+
+def is_path_allowed(user_path: str, allowed_dir: str) -> bool:
+ # Convert to absolute paths
+ user_path = Path(user_path).resolve()
+ allowed_dir = Path(allowed_dir).resolve()
+
+ try:
+ # Check if the user path is within the allowed directory
+ user_path.relative_to(allowed_dir)
+ return True
+ except ValueError:
+ return False
+
+
+class Forms:
+ def get_form_body(file="1"):
+ form_file = os.path.join(os.getcwd(), "app/forms", f"{file}.json")
+ allowed_paths = "app/forms"
+ if not is_path_allowed(form_file, allowed_paths):
+ logger.error("attempted to access unauthorized paths")
+ raise PermissionError("Access to the specified file is not allowed")
+ try:
+ return json.load(open(form_file, "r"))
+ except FileNotFoundError:
+ raise FileNotFoundError
+
+
+def fuzzy_parse_value(value):
+ # Convert common boolean-like values
+ if isinstance(value, str):
+ value_test = value.lower()
+ if value_test in {"yes", "true", "1", "Yes"}:
+ return True
+ if value_test in {"no", "false", "0", "No"}:
+ return False
+ if "i promise not" in value_test:
+ return True
+
+ # Convert other types as needed
+
+ return value
+
+
+def apply_fuzzy_parsing(data: dict):
+ """
+ Converts form data from fuzzy boolean values like, yes, no, 'i promise not' into booleans
+ """
+ parsed_data = {k: fuzzy_parse_value(v) for k, v in data.items()}
+ return parsed_data
+
+
+def transform_dict(d):
+ """
+ Turns the nested Models in the format nested_model.key1: "1" into nested_model: {key1: "1", key2: "2" }
+ """
+ if not any("." in key for key in d):
+ return d
+ nested_dict = DefaultDict(dict)
+ for key, value in d.items():
+ parent, child = key.split(".")
+ nested_dict[parent][child] = value
+ return nested_dict
diff --git a/util/horsepass.py b/app/util/horsepass.py
similarity index 100%
rename from util/horsepass.py
rename to app/util/horsepass.py
diff --git a/util/kennelish.py b/app/util/kennelish.py
similarity index 95%
rename from util/kennelish.py
rename to app/util/kennelish.py
index f299d26..d14d7c0 100644
--- a/util/kennelish.py
+++ b/app/util/kennelish.py
@@ -5,6 +5,7 @@
logger = logging.getLogger(__name__)
+
# Known bug: You cannot pre-fill data stored in second-level DynamoDB levels.
# So "parent.child" won't retrieve a value.
class Kennelish:
@@ -68,7 +69,7 @@ def header(entry, user_data=None, tag="h1"):
return output
def signature(entry, user_data=None):
- output = f"By submitting this form, you, {user_data.get('first_name', 'HackUCF Member #' + user_data.get('id'))} {user_data.get('surname', '')}, agree to the above terms. This form will be time-stamped.
"
+ output = f"By submitting this form, you, {user_data.get('first_name', 'HackUCF Member #' + str(user_data.get('id')))} {user_data.get('surname', '')}, agree to the above terms. This form will be time-stamped.
"
return output
def text(entry, user_data=None, inp_type="text"):
@@ -224,27 +225,30 @@ def kennelish_to_form(json):
# For emails (specified domain)
elif element_type == "email" and el.get("domain", False):
- regex_constr = constr(
- regex="([A-Za-z0-9.-_+]+)@" + el.get("domain").lower()
- )
+ domain_regex = rf'^[A-Za-z0-9._%+-]+@{el.get("domain").lower()}$'
+ regex_constr = constr(pattern=domain_regex)
obj[el.get("key")] = (regex_constr, None)
# For emails (any domain)
elif element_type == "email":
regex_constr = constr(
- regex="([A-Za-z0-9.-_+]+)@[A-Za-z0-9-]+(.[A-Za-z-]{2,})"
+ pattern=r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"
)
obj[el.get("key")] = (regex_constr, None)
# For NIDs
elif element_type == "nid":
- regex_constr = constr(regex="(^([a-z]{2}[0-9]{6})$)")
+ regex_constr = constr(pattern="(^([a-z]{2}[0-9]{6})$)")
obj[el.get("key")] = (regex_constr, None)
# For numbers
elif element_type == "slider":
obj[el.get("key")] = (int, None)
+ # Timestamps
+ elif element_type == "signature":
+ obj[el.get("key")] = (int, None)
+
# For arbitrary strings.
elif el.get("key") is not None:
obj[el.get("key")] = (str, None)
diff --git a/util/limiter.py b/app/util/limiter.py
similarity index 100%
rename from util/limiter.py
rename to app/util/limiter.py
diff --git a/util/settings.py b/app/util/settings.py
similarity index 75%
rename from util/settings.py
rename to app/util/settings.py
index 707dd6d..004c149 100644
--- a/util/settings.py
+++ b/app/util/settings.py
@@ -3,6 +3,7 @@
import os
import re
import subprocess
+from typing import Optional
import yaml
from pydantic import BaseModel, SecretStr, constr
@@ -10,36 +11,42 @@
logger = logging.getLogger(__name__)
+
def BitwardenConfig(settings: dict):
- '''
+ """
Takes a dict of settings loaded from yaml and adds the secrets from bitwarden to the settings dict.
The bitwarden secrets are mapped to the settings dict using the bitwarden_mapping dict.
The secrets are sourced based on a project id in the settings dict.
- '''
+ """
logger.debug("Loading secrets from Bitwarden")
try:
- project_id = settings['bws']['project_id']
- if bool(re.search('[^a-z0-9-]', project_id)):
+ project_id = settings["bws"]["project_id"]
+ if bool(re.search("[^a-z0-9-]", project_id)):
raise ValueError("Invalid project id")
command = ["bws", "secret", "list", project_id, "--output", "json"]
env_vars = os.environ.copy()
- bitwarden_raw = subprocess.run(command, text=True, env=env_vars, capture_output=True).stdout
+ bitwarden_raw = subprocess.run(
+ command, text=True, env=env_vars, capture_output=True
+ ).stdout
except Exception as e:
logger.exception(e)
bitwarden_settings = parse_json_to_dict(bitwarden_raw)
bitwarden_mapping = {
- 'discord_bot_token': ('discord', 'bot_token'),
- 'discord_client_id': ('discord', 'client_id'),
- 'discord_secret': ('discord', 'secret'),
- 'stripe_api_key': ('stripe', 'api_key'),
- 'stripe_webhook_secret': ('stripe', 'webhook_secret'),
- 'stripe_price_id': ('stripe', 'price_id'),
- 'email_password': ('email', 'password'),
- 'jwt_secret': ('jwt', 'secret'),
- 'infra_wifi': ('infra', 'wifi'),
- 'infra_application_credential_id': ('infra', 'application_credential_id'),
- 'infra_configuration_credential_secret': ('infra', 'application_credential_secret')
+ "discord_bot_token": ("discord", "bot_token"),
+ "discord_client_id": ("discord", "client_id"),
+ "discord_secret": ("discord", "secret"),
+ "stripe_api_key": ("stripe", "api_key"),
+ "stripe_webhook_secret": ("stripe", "webhook_secret"),
+ "stripe_price_id": ("stripe", "price_id"),
+ "email_password": ("email", "password"),
+ "jwt_secret": ("jwt", "secret"),
+ "infra_wifi": ("infra", "wifi"),
+ "infra_application_credential_id": ("infra", "application_credential_id"),
+ "infra_configuration_credential_secret": (
+ "infra",
+ "application_credential_secret",
+ ),
}
bitwarden_mapped = {}
@@ -56,23 +63,27 @@ def BitwardenConfig(settings: dict):
settings[top_key][nested_key] = value
return settings
+
settings = dict()
# Reads config from ../config/options.yml
here = os.path.abspath(os.path.dirname(__file__))
-with open(os.path.join(here, "../config/options.yml")) as f:
+with open(os.path.join(here, "../../config/options.yml")) as f:
settings.update(yaml.load(f, Loader=yaml.FullLoader))
+
def parse_json_to_dict(json_string):
data = json.loads(json_string)
- return {item['key']: item['value'] for item in data}
+ return {item["key"]: item["value"] for item in data}
+
# If bitwarden is enabled, add secrets to settings
-if settings.get('bws').get('enable'):
+if settings.get("bws").get("enable"):
settings = BitwardenConfig(settings)
logger.debug("Final settings: %s", settings)
+
class DiscordConfig(BaseModel):
"""
Represents the configuration settings for Discord integration.
@@ -86,6 +97,7 @@ class DiscordConfig(BaseModel):
scope (str): The scope of permissions required for the Discord integration.
secret (SecretStr): The secret key for the Discord oauth.
"""
+
bot_token: SecretStr
client_id: int
guild_id: int
@@ -94,7 +106,10 @@ class DiscordConfig(BaseModel):
scope: str
secret: SecretStr
-discord_config = DiscordConfig(**settings['discord'])
+
+discord_config = DiscordConfig(**settings["discord"])
+
+
class StripeConfig(BaseModel):
"""
Configuration class for Stripe integration.
@@ -106,13 +121,17 @@ class StripeConfig(BaseModel):
url_success (str): The URL to redirect to on successful payment.
url_failure (str): The URL to redirect to on failed payment.
"""
+
api_key: SecretStr
webhook_secret: SecretStr
price_id: str
url_success: str
url_failure: str
pause_payments: bool
-stripe_config = StripeConfig(**settings['stripe'])
+
+
+stripe_config = StripeConfig(**settings["stripe"])
+
class EmailConfig(BaseModel):
"""
@@ -123,10 +142,14 @@ class EmailConfig(BaseModel):
email (str): The email address to send from also used as the login username.
password (SecretStr): The password for the email account.
"""
+
smtp_server: str
email: str
password: SecretStr
-email_config = EmailConfig(**settings['email'])
+
+
+email_config = EmailConfig(**settings["email"])
+
class JwtConfig(BaseModel):
"""
@@ -138,15 +161,15 @@ class JwtConfig(BaseModel):
lifetime_user (int): The lifetime (in seconds) of a user JWT.
lifetime_sudo (int): The lifetime (in seconds) of a sudo JWT.
"""
+
secret: SecretStr = constr(min_length=32)
algorithm: str
lifetime_user: int
lifetime_sudo: int
-jwt_config = JwtConfig(**settings['jwt'])
-class DynamodbConfig(BaseModel):
- table: str
-dynamodb_config = DynamodbConfig(**settings['aws']['dynamodb'])
+
+jwt_config = JwtConfig(**settings["jwt"])
+
class InfraConfig(BaseModel):
"""
@@ -159,22 +182,47 @@ class InfraConfig(BaseModel):
application_credential_secret (SecretStr): The application credential secret used to provision users and projects.
tf_directory (str): The Terraform directory.
"""
+
wifi: str
horizon: str
application_credential_id: str
application_credential_secret: SecretStr
tf_directory: str
-infra_config = InfraConfig(**settings['infra'])
+
+
+infra_config = InfraConfig(**settings["infra"])
+
+
+class TelemetryConfig(BaseModel):
+ url: Optional[str] = None
+ enable: Optional[bool] = False
+
+
+telemetry_config = TelemetryConfig(**settings["telemetry"])
+
+
+class DatabaseConfig(BaseModel):
+ url: str
+
+
+database_config = DatabaseConfig(**settings["database"])
+
class RedisConfig(BaseModel):
host: str
port: int
db: int
-redis_config = RedisConfig(**settings['redis'])
+
+
+redis_config = RedisConfig(**settings["redis"])
+
class HttpConfig(BaseModel):
domain: str
-http_config = HttpConfig(**settings['http'])
+
+
+http_config = HttpConfig(**settings["http"])
+
class SingletonBaseSettingsMeta(type(BaseSettings), type):
_instances = {}
@@ -183,12 +231,15 @@ def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
+
+
class Settings(BaseSettings, metaclass=SingletonBaseSettingsMeta):
discord: DiscordConfig = discord_config
stripe: StripeConfig = stripe_config
email: EmailConfig = email_config
jwt: JwtConfig = jwt_config
- aws: DynamodbConfig = dynamodb_config
+ database: DatabaseConfig = database_config
infra: InfraConfig = infra_config
redis: RedisConfig = redis_config
http: HttpConfig = http_config
+ telemetry: TelemetryConfig = telemetry_config
diff --git a/config.yml b/config.yml
index 382d41b..e9755ed 100644
--- a/config.yml
+++ b/config.yml
@@ -48,3 +48,7 @@ redis:
host: "localhost"
port: 6379
db: 0
+
+telementary:
+ url:
+ enable:
diff --git a/docker-compose-tests.yml b/docker-compose-tests.yml
new file mode 100644
index 0000000..98c4b37
--- /dev/null
+++ b/docker-compose-tests.yml
@@ -0,0 +1,46 @@
+services:
+ fastapi:
+ build:
+ context: .
+ dockerfile: Dockerfile-testing
+ environment:
+ - BWS_ACCESS_TOKEN={$BWS_ACCESS_TOKEN}
+ ports:
+ - 8000:8000
+ volumes:
+ - ./config/options.yml:/app/config/options.yml
+ - $HOME/.aws:/root/.aws
+ develop:
+ watch:
+ # sync static content
+ - path: ./static
+ action: sync
+ target: /app/static
+ # sync templates
+ - path: ./templates
+ action: sync
+ target: /app/templates
+ - path: ./forms
+ action: sync
+ target: /app/templates
+ # sync app
+ - path: ./models/
+ action: sync+restart
+ target: /app/models/
+ - path: ./routes/
+ action: sync+restart
+ target: /app/routes/
+ - path: ./tests/
+ action: rebuild
+ target: /app/tests/
+ - path: ./util/
+ action: sync+restart
+ target: /app/util/
+ - path: ./index.py
+ action: sync+restart
+ target: /app/index.py
+ - path: ./requirements.txt
+ action: rebuild
+ target: /app/requirements.txt
+ redis:
+ image: redis:7.2
diff --git a/docker-compose.yml b/docker-compose.yml
index 3e3916d..9593090 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,3 @@
-version: '3'
services:
fastapi:
build: .
@@ -7,33 +6,36 @@ services:
ports:
- 8000:8000
volumes:
- - ./config/options.yml:/app/config/options.yml
- - $HOME/.aws:/root/.aws
+ - ./config/options.yml:/src/config/options.yml
+ - ./database/:/data/
develop:
watch:
# sync static content
- - path: ./static
+ - path: ./app/static
action: sync
- target: /app/static
+ target: /src/app/static
# sync templates
- - path: ./templates
+ - path: ./app/templates
action: sync
- target: /app/templates
+ target: /src/app/templates
+ - path: ./app/forms
+ action: sync
+ target: /src/app/templates
# sync app
- - path: ./models/
+ - path: ./app/app/models/
action: sync+restart
- target: /app/models/
- - path: ./routes/
+ target: /src/app/models/
+ - path: ./app/routes/
action: sync+restart
- target: /app/routes/
- - path: ./util/
+ target: /src/app/routes/
+ - path: ./app/util/
action: sync+restart
- target: /app/util/
- - path: ./index.py
+ target: /src/app/util/
+ - path: ./app/index.py
action: sync+restart
- target: /app/index.py
+ target: /src/app/index.py
- path: ./requirements.txt
action: rebuild
- target: /app/requirements.txt
+ target: /src/app/requirements.txt
redis:
image: redis:7.2
diff --git a/models/user.py b/models/user.py
deleted file mode 100644
index 4787b81..0000000
--- a/models/user.py
+++ /dev/null
@@ -1,147 +0,0 @@
-from typing import Optional
-
-from pydantic import BaseModel
-
-
-class DiscordModel(BaseModel):
- email: Optional[str] = None
- mfa: Optional[bool] = None
- avatar: Optional[str] = None
- banner: Optional[str] = None
- color: Optional[int] = None
- nitro: Optional[int] = None
- locale: Optional[str] = None
- username: str
-
-
-class EthicsFormModel(BaseModel):
- hack_others: Optional[bool] = False
- hack_ucf: Optional[bool] = False
- interrupt_ucf: Optional[bool] = False
- manip_traffic: Optional[bool] = False
- bypass_dhcp: Optional[bool] = False
- pirate: Optional[bool] = False
- host_at_ucf: Optional[bool] = False
- signtime: Optional[int] = 0
-
-
-class CyberLabModel(BaseModel):
- resource: Optional[bool] = False
- clean: Optional[bool] = False
- no_profane: Optional[bool] = False
- access_control: Optional[bool] = False
- report_damage: Optional[bool] = False
- be_nice: Optional[bool] = False
- can_revoke: Optional[bool] = False
- signtime: Optional[int] = 0
-
-
-class MenteeModel(BaseModel):
- schedule: Optional[str] = None
- time_in_cyber: Optional[str] = None
- personal_proj: Optional[str] = None
- hope_to_gain: Optional[str] = None
- domain_interest: Optional[str] = None
-
-
-class UserModel(BaseModel):
- # Identifiers
- id: str
- discord_id: str
- ucf_id: Optional[int] = None
- nid: Optional[str] = None
- ops_email: Optional[str] = None
- infra_email: Optional[str] = None
-
- minecraft: Optional[str] = ""
- github: Optional[str] = ""
-
- # PII
- first_name: Optional[str] = ""
- surname: Optional[str] = ""
- email: Optional[str] = ""
- is_returning: Optional[bool] = False
- gender: Optional[str] = ""
- major: Optional[str] = ""
- class_standing: Optional[str] = ""
- shirt_size: Optional[str] = ""
- did_get_shirt: Optional[bool] = False
- time_availability: Optional[str] = ""
- phone_number: Optional[int] = 0
-
- # Permissions and Member Status
- sudo: Optional[bool] = False
- did_pay_dues: Optional[bool] = False
- join_date: Optional[int] = None
-
- # Paperwork Signed
- ethics_form: Optional[EthicsFormModel] = EthicsFormModel()
- cyberlab_monitor: Optional[CyberLabModel] = CyberLabModel()
-
- # Mentorship Program
- mentee: Optional[MenteeModel] = MenteeModel()
- mentor_name: Optional[str] = None
-
- is_full_member: Optional[bool] = False
- can_vote: Optional[bool] = False
-
- # Other models
- discord: DiscordModel
- experience: Optional[int] = None
- curiosity: Optional[str] = None
- c3_interest: Optional[bool] = False
-
- # Other things
- attending: Optional[str] = ""
- comments: Optional[str] = ""
-
-
-# What admins can edit.
-class UserModelMutable(BaseModel):
- # Identifiers
- id: str
- discord_id: Optional[str] = None
- ucf_id: Optional[int] = None
- nid: Optional[str] = None
- ops_email: Optional[str] = None
- infra_email: Optional[str] = None
-
- minecraft: Optional[str] = None
- github: Optional[str] = None
-
- # PII
- first_name: Optional[str] = None
- surname: Optional[str] = None
- email: Optional[str] = None
- is_returning: Optional[bool] = None
- gender: Optional[str] = None
- major: Optional[str] = None
- class_standing: Optional[str] = None
- shirt_size: Optional[str] = None
- did_get_shirt: Optional[bool] = None
- phone_number: Optional[int] = None
-
- # Permissions and Member Status
- sudo: Optional[bool] = None
- did_pay_dues: Optional[bool] = None
-
- # Mentorship Program
- mentor_name: Optional[str] = None
-
- is_full_member: Optional[bool] = None
- can_vote: Optional[bool] = False
-
- # Other models
- experience: Optional[int] = None
- curiosity: Optional[str] = None
- c3_interest: Optional[bool] = None
-
- # Other things
- attending: Optional[str] = None
- comments: Optional[str] = None
-
-
-class PublicContact(BaseModel):
- first_name: str
- surname: str
- ops_email: str
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..e4c7a44
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+; log_cli=true
+; log_level=INFO
+addopts = -p no:warnings
diff --git a/requirements.txt b/requirements.txt
index e7f0060..33771dd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,5 @@
airpress==1.0.3
asyncio==3.4.3
-boto3==1.34.102
cryptography==39.0.1
commonmark==0.9.1
fastapi==0.111.0
@@ -24,3 +23,9 @@ stripe==9.6.0
typing_extensions==4.11.0
uvicorn==0.29.0
virtualenv==20.26.1
+httpx
+pytest
+sqlmodel
+sentry_sdk
+alembic
+aiosqlite
diff --git a/routes/api.py b/routes/api.py
deleted file mode 100644
index dea1305..0000000
--- a/routes/api.py
+++ /dev/null
@@ -1,186 +0,0 @@
-import json
-from typing import Optional
-
-import boto3
-from botocore.exceptions import ClientError
-from fastapi import APIRouter, Cookie, HTTPException, Request
-from fastapi.responses import HTMLResponse
-from pydantic import error_wrappers
-
-from models.info import InfoModel
-from models.user import PublicContact
-from util.authentication import Authentication
-from util.errors import Errors
-from util.forms import Forms
-from util.kennelish import Kennelish, Transformer
-from util.settings import Settings
-
-router = APIRouter(prefix="/api", tags=["API"], responses=Errors.basic_http())
-
-
-"""
-Get API information.
-"""
-
-
-@router.get("/")
-async def get_root():
- return InfoModel(
- name="OnboardLite",
- description="Hack@UCF's in-house membership management suite.",
- credits=[
- PublicContact(
- first_name="Jeffrey",
- surname="DiVincent",
- ops_email="jdivincent@hackucf.org",
- )
- ],
- )
-
-
-"""
-Gets the JSON markup for a Kennelish file. For client-side rendering (if that ever becomes a thing).
-Note that Kennelish form files are NOT considered sensitive.
-"""
-
-
-@router.get("/form/{num}")
-async def get_form(num: str):
- try:
- return Forms.get_form_body(num)
- except FileNotFoundError:
- return HTTPException(status_code=404, detail="Form not found")
-
-
-"""
-Renders a Kennelish form file as HTML (with user data). Intended for AJAX applications.
-"""
-
-
-@router.get("/form/{num}/html", response_class=HTMLResponse)
-@Authentication.member
-async def get_form_html(
- request: Request,
- token: Optional[str] = Cookie(None),
- user_jwt: Optional[object] = {},
- num: str = 1,
-):
- # AWS dependencies
- dynamodb = boto3.resource("dynamodb")
- table = dynamodb.Table(Settings().aws.table)
-
- # Get form object
- try:
- data = Forms.get_form_body(num)
- except FileNotFoundError:
- return HTTPException(status_code=404, detail="Form not found")
- # Get data from DynamoDB
- user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None)
-
- # Have Kennelish parse the data.
- body = Kennelish.parse(data, user_data)
-
- return body
-
-
-"""
-Allows updating the user's database using a schema assumed by the Kennelish file.
-"""
-
-
-@router.post("/form/{num}")
-@Authentication.member
-async def post_form(
- request: Request,
- token: Optional[str] = Cookie(None),
- user_jwt: Optional[object] = {},
- num: str = 1,
-):
- # Get Kennelish data
- try:
- kennelish_data = Forms.get_form_body(num)
- except FileNotFoundError:
- return HTTPException(status_code=404, detail="Form not found")
-
- model = Transformer.kennelish_to_pydantic(kennelish_data)
-
- # Parse and Validate inputs
- try:
- inp = await request.json()
- except json.JSONDecodeError:
- return {"description": "Malformed JSON input."}
-
- try:
- # this only parses the data into an arbitrary pydantic model,
- # it doesn't actually validate form field completion as far as I can tell
- validated = model(**inp)
- except error_wrappers.ValidationError:
- return {"description": "Malformed input."}
-
- # Remove items we did not update
- items_to_update = list(validated.dict().items())
- items_to_keep = []
- for item in items_to_update:
- # What is Item[0] and Item[1]???
- if item[1] is not None:
- # English -> Boolean
- if item[1] == "Yes" or item[1] == "I promise not to do this.":
- item = (item[0], True)
- elif (
- item[1] == "No"
- or item[1]
- == "I disagree with this and do not wish to be part of Hack@UCF"
- ):
- item = (item[0], False)
-
- items_to_keep.append(item)
-
- update_expression = "SET "
- expression_attribute_values = {}
-
- # Here, the variable 'items_to_keep' is validated input. We can update the user's profile from here.
-
- # Prepare to update to DynamoDB
- for item in items_to_keep:
- update_expression += f"{item[0]} = :{item[0].replace('.', '_')}, "
- expression_attribute_values[f":{item[0].replace('.', '_')}"] = item[1]
-
- # Strip last comma for update_expression
- update_expression = update_expression[:-2]
-
- # AWS dependencies
- dynamodb = boto3.resource("dynamodb")
- table = dynamodb.Table(Settings().aws.table)
-
- # Push data back to DynamoDB
- try:
- table.update_item(
- Key={"id": user_jwt.get("id")},
- UpdateExpression=update_expression,
- ExpressionAttributeValues=expression_attribute_values,
- )
- except ClientError:
- # We need to do a migration on *something*. We know it's a subtype.
- # So we will find it and migrate it.
- for item in items_to_keep:
- if "." in item[0]:
- dot_loc = item[0].find(".")
- key_to_make = item[0][:dot_loc]
-
- # Create dictionary
- table.update_item(
- Key={"id": user_jwt.get("id")},
- # key_to_make is not user-supplied, rather, it's from the form JSON.
- # if this noSQLi's, then it's because of an insider threat.
- UpdateExpression=f"SET {key_to_make} = :dicty",
- ExpressionAttributeValues={":dicty": {}},
- )
-
- # After all dicts are a thing, re-run query.
- table.update_item(
- Key={"id": user_jwt.get("id")},
- UpdateExpression=update_expression,
- ExpressionAttributeValues=expression_attribute_values,
- )
-
- return validated
diff --git a/routes/infra.py b/routes/infra.py
deleted file mode 100644
index bed924e..0000000
--- a/routes/infra.py
+++ /dev/null
@@ -1,340 +0,0 @@
-import asyncio
-import json
-import logging
-import os
-from typing import Optional
-
-import boto3
-import openstack
-from fastapi import APIRouter, Cookie, Request
-from fastapi.responses import FileResponse
-from fastapi.templating import Jinja2Templates
-from python_terraform import Terraform
-
-from models.info import InfoModel
-from models.user import PublicContact
-from util.approve import Approve
-from util.authentication import Authentication
-from util.discord import Discord
-from util.email import Email
-from util.errors import Errors
-from util.limiter import RateLimiter
-from util.settings import Settings
-
-logger = logging.getLogger(__name__)
-
-
-templates = Jinja2Templates(directory="templates")
-
-router = APIRouter(prefix="/infra", tags=["Infra"], responses=Errors.basic_http())
-
-tf = Terraform(working_dir="./")
-
-rate_limiter = RateLimiter(
- Settings().redis.host, Settings().redis.port, Settings().redis.db
-)
-
-rate_limiter.get_redis()
-
-
-def get_shitty_database():
- """
- Dump contents of the file that stores infra options.
- I lovingly call this the "shitty database."
- """
- data = {}
- opts_path = "infra_options.json"
- try:
- with open(opts_path, "r") as f:
- data = json.loads(f.read())
- except Exception as e:
- logger.exception(f"Invalid config file at {opts_path}", e)
- data = {"gbmName": None, "imageId": None}
-
- return data
-
-
-async def create_resource(project, callback_discord_id=None):
- shitty_database = get_shitty_database()
- proj_name = project.name
-
- logger.info(f"Creating resources for {proj_name}...")
-
- tf_vars = {
- "application_credential_id": Settings().infra.application_credential_id,
- "application_credential_secret": Settings().infra.application_credential_secret.get_secret_value(),
- "tenant_name": proj_name,
- "gbmname": shitty_database.get("gbmName"),
- "imageid": shitty_database.get("imageId"),
- "member_username": project.id,
- }
- return_code, stdout, stderr = tf.apply(var=tf_vars, skip_plan=True)
- if return_code != 0:
- logger.exception("Terraform failed!")
- logger.debug(f"\treturn: {return_code}")
- logger.debug(f"\tstderr: {stderr}\n")
-
- # clean up
- try:
- os.remove("terraform.tfstate")
- except Exception:
- pass
-
- try:
- os.remove("terraform.tfstate.backup")
- except Exception:
- pass
-
- if callback_discord_id:
- resource_create_msg = f"""Hello!
-
-Your requested virtual machine has been created! You can now view it at {Settings().infra.horizon}.
-
-Enjoy,
- - Hack@UCF Bot
-"""
- Discord.send_message(callback_discord_id, resource_create_msg)
-
- logger.info("\tDone!")
-
-
-async def teardown():
- logger.debug("Initializing post-GBM teardown...")
- death_word = "gbm"
-
- conn = openstack.connect(cloud="hackucf_infra")
-
- logger.debug("\tServers...")
- for resource in conn.compute.servers(all_projects=True):
- # logger.debug("\t" + resource.name)
- if death_word in resource.name.lower():
- logger.debug(f"\t\tdelete {resource.name}")
- conn.compute.delete_server(resource)
-
- logger("\tSec Groups...")
- for resource in conn.network.security_groups():
- # logger.debug("\t" + resource.name)
- if death_word in resource.name.lower():
- logger.debug(f"\t\tdelete {resource.name}")
- conn.network.delete_security_group(resource)
-
- logger.debug("\tRouters...")
- for resource in conn.network.routers():
- # logger.debug("\t" + resource.name)
- if death_word in resource.name.lower():
- logger.debug(f"\t\tdelete {resource.name}")
- try:
- conn.network.delete_router(resource)
- except openstack.exceptions.ConflictException as e:
- port_id_list = str(e).split(": ")[-1].split(",")
- for port_id in port_id_list:
- logger.debug(f"\t\t\tdelete/abandon port: {port_id}")
- conn.network.remove_interface_from_router(resource, port_id=port_id)
- conn.network.delete_port(port_id)
- try:
- conn.network.delete_router(resource)
- except: # noqa
- logger.debug("\t\t\t\tFailed and gave up.")
-
- logger.debug("\tNetworks...")
- for resource in conn.network.networks():
- # logger.debug("\t" + resource.name)
- if death_word in resource.name.lower():
- logger.debug(f"\t\tdelete {resource.name}")
- try:
- conn.network.delete_network(resource)
- except openstack.exceptions.ConflictException as e:
- port_id_list = str(e).split(": ")[-1][:-1].split(",")
- for port_id in port_id_list:
- logger.debug(f"\t\t\tdelete port: {port_id}")
- try:
- conn.network.delete_port(port_id)
- except: # noqa
- pass
- try:
- conn.network.delete_network(resource)
- except: #noqa
- logger.debug("\t\t\t\tFailed and gave up.")
- logger.debug("\tDone!")
-
-
-"""
-Get API information.
-"""
-
-
-@router.get("/")
-async def get_root():
- return InfoModel(
- name="Onboard Infra",
- description="Infrastructure Management via Onboard.",
- credits=[
- PublicContact(
- first_name="Jeffrey",
- surname="DiVincent",
- ops_email="jdivincent@hackucf.org",
- ),
- PublicContact(
- first_name="Caleb",
- surname="Sjostedt",
- ops_email="csjostedt@hackucf.org",
- ),
- ],
- )
-
-
-"""
-API endpoint to self-service create a GBM environment.
-"""
-
-
-@router.get("/provision/")
-@Authentication.member
-async def get_provision(
- request: Request,
- token: Optional[str] = Cookie(None),
- user_jwt: Optional[object] = {},
-):
- conn = openstack.connect(cloud="hackucf_infra")
-
- # Get single user
- user = conn.identity.find_user(user_jwt.get("infra_email"))
-
- # Get project
- project = conn.identity.get_project(user.default_project_id)
-
- # Provision everything
- asyncio.create_task(
- create_resource(project, user_jwt.get("discord_id"))
- ) # runs teardown async
- return {"msg": "Queued."}
-
-
-"""
-API endpoint to trigger tear-down of GBM-provisioned stuff.
-"""
-
-
-@router.get("/teardown/")
-@Authentication.admin
-async def get_teardown(request: Request, token: Optional[str] = Cookie(None)):
- asyncio.create_task(teardown()) # runs teardown async
- return {"msg": "Queued."}
-
-
-"""
-API endpoint to SET the one-click deploy Settings().
-"""
-
-
-@router.get("/options/get")
-@Authentication.member
-async def get_options(
- request: Request,
- token: Optional[str] = Cookie(None),
- user_jwt: Optional[object] = {},
-):
- return get_shitty_database()
-
-
-"""
-API endpoint to SET the one-click deploy Settings().
-"""
-
-
-@router.get("/options/set")
-@Authentication.admin
-async def set_options(
- request: Request,
- token: Optional[str] = Cookie(None),
- gbmName: Optional[str] = None,
- imageId: Optional[str] = None,
-):
- shitty_database = {"gbmName": gbmName, "imageId": imageId}
-
- with open("infra_options.json", "w") as f:
- f.write(json.dumps(shitty_database))
-
- return shitty_database
-
-
-"""
-API endpoint to self-service reset Infra credentials (membership-validating)
-"""
-
-
-@router.get("/reset/")
-@Authentication.member
-@rate_limiter.rate_limit(1, 604800, "reset")
-async def get_infra(
- request: Request,
- token: Optional[str] = Cookie(None),
- user_jwt: Optional[object] = {},
-):
- member_id = user_jwt.get("id")
-
- if not (user_jwt.get("is_full_member") or user_jwt.get("infra_email")):
- return Errors.generate(
- request, 403, "This API endpoint is restricted to Dues-Paying Members."
- )
-
- # This also reprovisions Infra access if an account already exists.
- # This is useful for cleaning up things + nuking in case of an error.
- creds = Approve.provision_infra(member_id)
-
- if not creds:
- creds = {}
-
- # Get user data
- dynamodb = boto3.resource("dynamodb")
- table = dynamodb.Table(Settings().aws.table)
-
- user_data = table.get_item(Key={"id": member_id}).get("Item", None)
-
- # Send DM...
- new_creds_msg = f"""Hello {user_data.get('first_name')},
-
-You have requested to reset your Hack@UCF Infrastructure credentials. This change comes with new credentials.
-
-A reminder that you can use these credentials at {Settings().infra.horizon} while on the CyberLab WiFi.
-
-```
-Username: {creds.get('username', 'Not Set')}
-Password: {creds.get('password', f"Please visit https://{Settings().http.domain}/profile and under Danger Zone, reset your Infra creds.")}
-```
-
-The password for the `Cyberlab` WiFi is currently `{Settings().infra.wifi}`, but this is subject to change (and we'll let you know when that happens).
-
-By using the Hack@UCF Infrastructure, you agree to the following EULA located at https://help.hackucf.org/misc/eula
-
-Happy Hacking,
-
- - Hack@UCF Bot
- """
-
- # Send Discord message
- # Discord.send_message(user_data.get("discord_id"), new_creds_msg)
- # Send Email
- Email.send_email("Reset Infra Credentials", new_creds_msg, user_data.get("email"))
-
- return {"username": creds.get("username"), "password": creds.get("password")}
-
-
-"""
-An endpoint to Download OpenVPN profile
-"""
-
-
-@router.get("/openvpn")
-@Authentication.member
-@rate_limiter.rate_limit(5, 60, "ovpn")
-async def download_file(
- request: Request,
- token: Optional[str] = Cookie(None),
- user_jwt: Optional[object] = {},
-):
- # Replace 'path/to/your/file.txt' with the actual path to your file
- file_path = "./HackUCF.ovpn"
- return FileResponse(
- file_path, filename="HackUCF.ovpn", media_type="application/octet-stream"
- )
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 0000000..4b8f16e
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,7 @@
+exclude = [
+ "tests"
+]
+
+
+[lint]
+ignore = ["F401"]
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..ed06257
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,92 @@
+import os
+import uuid
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlmodel import Session, SQLModel, create_engine, inspect
+from sqlmodel.pool import StaticPool
+
+from app.main import app, get_session
+from app.models.user import DiscordModel, UserModel
+from app.util.authentication import Authentication
+
+
+@pytest.fixture(name="engine")
+def engine_fixture():
+ url = f"sqlite://"
+ engine = create_engine(
+ url, connect_args={"check_same_thread": False}, poolclass=StaticPool
+ )
+ SQLModel.metadata.create_all(engine)
+ return engine
+
+
+@pytest.fixture(name="session")
+def session_fixture(engine):
+ with Session(engine) as session:
+ yield session
+
+
+@pytest.fixture(name="client")
+def client_fixture(session: Session):
+ def get_session_override():
+ return session
+
+ app.dependency_overrides[get_session] = get_session_override
+ client = TestClient(app)
+ yield client
+ app.dependency_overrides.clear()
+
+
+@pytest.fixture(name="test_user")
+def test_user_fixture(session: Session):
+ test_user_discord = DiscordModel(
+ id=1,
+ email="test_user@example.com",
+ mfa=False,
+ banner="https://upload.wikimedia.org/wikipedia/commons/e/e1/Banner_on_Wikivoyage.png",
+ avatar="https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Style_-_Wouldn%27t_It_Be_Nice.png/600px-Style_-_Wouldn%27t_It_Be_Nice.png",
+ color="1738207",
+ nitro=False,
+ locale="en_US",
+ username="test_user",
+ )
+ test_user = UserModel(
+ id=uuid.uuid4(),
+ discord_id="669276074563666347",
+ ucf_id=123456,
+ nid="ko123456",
+ ops_email="ops_test@example.com",
+ infra_email="infra_test@example.com",
+ minecraft="test_minecraft",
+ github="test_github",
+ first_name="Test",
+ surname="User",
+ email="test_user@example.com",
+ is_returning=False,
+ gender="M",
+ major="Computer Science",
+ class_standing="Senior",
+ shirt_size="M",
+ did_get_shirt=False,
+ phone_number=1234567890,
+ sudo=False,
+ did_pay_dues=False,
+ mentor_name="Test Mentor",
+ is_full_member=True,
+ can_vote=False,
+ experience=1,
+ curiosity="Very curious",
+ c3_interest=False,
+ attending="Yes",
+ comments="Test comments",
+ discord=test_user_discord,
+ )
+ session.add(test_user)
+ session.commit()
+ return test_user
+
+
+@pytest.fixture(name="jwt")
+def jwt_fixture(test_user: UserModel):
+ return Authentication.create_jwt(test_user)
diff --git a/tests/test_static.py b/tests/test_static.py
new file mode 100644
index 0000000..9b4ca40
--- /dev/null
+++ b/tests/test_static.py
@@ -0,0 +1,31 @@
+import os
+import sys
+
+from fastapi.testclient import TestClient
+
+# Add the project root to the PYTHONPATH
+import app.main
+from app.main import app
+
+client = TestClient(app)
+
+
+def test_get_static():
+ test_files = [
+ "admin.js",
+ "admin_logo.svg",
+ "apple_wallet.svg",
+ "favicon.ico",
+ "favicon.svg",
+ "form.js",
+ "hackucf.css",
+ "index.html",
+ "lib/qr-scanner.umd.min.js",
+ "lib/qr-scanner.min.js",
+ "lib/qr-scanner-worker.min.js",
+ "qr_hack_dark.svg",
+ "qr_hack_light.svg",
+ ]
+ for file in test_files:
+ get_static = client.get("/static/" + file)
+ assert get_static.status_code == 200, f"Failed to retrieve file: {file}"
diff --git a/tests/test_user.py b/tests/test_user.py
new file mode 100644
index 0000000..cfb3f27
--- /dev/null
+++ b/tests/test_user.py
@@ -0,0 +1,31 @@
+from unittest.mock import patch
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlmodel import Session
+
+from app.models.user import UserModel
+
+
+# jwt: str
+@patch("app.util.approve.Approve.approve_member", return_value=None)
+def test_profile(mock_approve, client: TestClient, jwt: str):
+ response = client.get("/profile/", cookies={"token": jwt})
+ # response = client.get("/profile")
+ assert response.status_code == 200
+ assert "test_user@example.com" in response.text
+
+
+def test_openvpn(client: TestClient, jwt: str):
+ response = client.get("/infra/openvpn/", cookies={"token": jwt})
+ assert response.status_code == 200
+
+
+def test_db(client: TestClient, session: Session, jwt: str):
+ user_in_db = (
+ session.query(UserModel)
+ .filter(UserModel.discord_id == "669276074563666347")
+ .first()
+ )
+ assert user_in_db is not None
+ assert user_in_db.email == "test_user@example.com"
diff --git a/util/approve.py b/util/approve.py
deleted file mode 100644
index aeb362b..0000000
--- a/util/approve.py
+++ /dev/null
@@ -1,222 +0,0 @@
-import logging
-import os
-
-import boto3
-import openstack
-from python_terraform import Terraform
-
-from util.discord import Discord
-from util.email import Email
-from util.horsepass import HorsePass
-from util.settings import Settings
-
-logger = logging.getLogger()
-
-
-tf = Terraform(working_dir=Settings().infra.tf_directory)
-
-"""
-This function will ensure a member meets all requirements to be a member, and if so, creates an
-Infra account + whitelist them to the Hack@UCF Minecraft server.
-
-If approval fails, dispatch a Discord message saying that something went wrong and how to fix it.
-"""
-
-
-class Approve:
- def __init__(self):
- pass
-
- def provision_infra(member_id, user_data=None):
- # Log into OpenStack
- conn = openstack.connect(cloud="hackucf_infra")
-
- try:
- os.remove("terraform.tfstate")
- except Exception:
- pass
-
- try:
- os.remove("terraform.tfstate.backup")
- except Exception:
- pass
-
- try:
- dynamodb = boto3.resource("dynamodb")
- table = dynamodb.Table(Settings().aws.table)
- if not user_data:
- user_data = table.get_item(Key={"id": member_id}).get("Item", None)
-
- # See if existing email.
- username = user_data.get("infra_email", False)
- if username:
- user = conn.identity.find_user(username)
- if user:
- # Delete user's default project
- logger.debug(f"user // {user.default_project_id}")
- proj = conn.identity.get_project(user.default_project_id)
- proj = conn.identity.delete_project(proj)
-
- # Delete user
- conn.identity.delete_user(user)
- logger.debug(f"{username}: User deleted.")
- else:
- logger.debug(f"{username}: No user.")
-
- else:
- username = (
- user_data.get("discord", {}).get("username").replace(" ", "_")
- + "@infra.hackucf.org"
- )
- # Add username to Onboard database
- table.update_item(
- Key={"id": member_id},
- UpdateExpression="SET infra_email = :val",
- ExpressionAttributeValues={":val": username},
- )
-
- password = HorsePass.gen()
-
- ###
- # Let's create a new OpenStack user with the SDK!
- ###
-
- # Create a project for the new users
- try:
- new_proj = conn.identity.create_project(
- name=member_id,
- description="Automatically provisioning with Hack@UCF Onboard",
- )
- except openstack.exceptions.ConflictException:
- # This happens sometimes.
- new_proj = conn.identity.find_project("member_id")
-
- # Create account and important resources via Terraform magics.
- new_user = conn.identity.create_user(
- default_project_id=new_proj.id,
- name=username,
- description="Hack@UCF Dues Paying Member",
- password=password,
- )
-
- # Find member role + assign it to user and project
- member_role = conn.identity.find_role("member")
- conn.identity.assign_project_role_to_user(
- project=new_proj, user=new_user, role=member_role
- )
-
- # Find admin role + assign it to Onboard user + user project
- admin_role = conn.identity.find_role("admin")
- conn.identity.assign_project_role_to_user(
- project=new_proj,
- user=conn.identity.find_user("onboard-service"),
- role=admin_role,
- )
-
- ## Push account to OpenStack via Terraform magics (not used rn)
- # tf_vars = {'os_password': options.get('infra', {}).get('ad', {}).get('password'), 'tenant_name': member_id, 'handle': username, 'password': password}
- # tf.apply(var=tf_vars, skip_plan=True)
-
- return {"username": username, "password": password}
- except Exception as e:
- logger.exception(e)
- return None
-
- # !TODO finish the post-sign-up stuff + testing
- def approve_member(member_id):
- logger.info(f"Re-running approval for {member_id}")
- dynamodb = boto3.resource("dynamodb")
- table = dynamodb.Table(Settings().aws.table)
-
- user_data = table.get_item(Key={"id": member_id}).get("Item", None)
-
- # If a member was already approved, kill process.
- if user_data.get("is_full_member", False):
- logger.info("\tAlready full member.")
- return True
-
- # Sorry for the long if statement. But we consider someone a "member" iff:
- # - They have a name
- # - We have their Discord snowflake
- # - They paid dues
- # - They signed their ethics form
- if (
- user_data.get("first_name")
- and user_data.get("discord_id")
- and user_data.get("did_pay_dues")
- and user_data.get("ethics_form", {}).get("signtime", 0) != 0
- ):
- logger.info("\tNewly-promoted full member!")
-
- discord_id = user_data.get("discord_id")
-
- # Create an Infra account.
- creds = Approve.provision_infra(
- member_id, user_data=user_data
- ) # TODO(err): sometimes this is None
- if creds is None:
- creds = {}
-
- # Minecraft server
- if user_data.get("minecraft", False):
- pass
- #
-
- # Assign the Dues-Paying Member role
- Discord.assign_role(
- discord_id, Settings().discord.member_role
- )
-
- # Send Discord message saying they are a member
- welcome_msg = f"""Hello {user_data.get('first_name')}, and welcome to Hack@UCF!
-
-This message is to confirm that your membership has processed successfully. You can access and edit your membership ID at https://{Settings().http.domain}/profile.
-
-These credentials can be used to the Hack@UCF Private Cloud, one of our many benefits of paying dues. This can be accessed at {Settings().infra.horizon} while on the CyberLab WiFi.
-
-```yaml
-Username: {creds.get('username', 'Not Set')}
-Password: {creds.get('password', f"Please visit https://{Settings().http.domain}/profile and under Danger Zone, reset your Infra creds.")}
-```
-
-The password for the `Cyberlab` WiFi is currently `{Settings().infra.wifi}`, but this is subject to change (and we'll let you know when that happens).
-
-By using the Hack@UCF Infrastructure, you agree to the following EULA located at https://help.hackucf.org/misc/eula
-
-Happy Hacking,
- - Hack@UCF Bot
- """
-
- Discord.send_message(discord_id, welcome_msg)
- Email.send_email("Welcome to Hack@UCF", welcome_msg, user_data.get("email"))
- # Set member as a "full" member.
- table.update_item(
- Key={"id": member_id},
- UpdateExpression="SET is_full_member = :val",
- ExpressionAttributeValues={":val": True},
- )
-
- elif user_data.get("did_pay_dues"):
- logger.info("\tPaid dues but did not do other step!")
- # Send a message on why this check failed.
- fail_msg = f"""Hello {user_data.get('first_name')},
-
-We wanted to let you know that you **did not** complete all of the steps for being able to become an Hack@UCF member.
-
-- Provided a name: {'✅' if user_data.get('first_name') else '❌'}
-- Signed Ethics Form: {'✅' if user_data.get('ethics_form', {}).get('signtime', 0) != 0 else '❌'}
-- Paid $10 dues: ✅
-
-Please complete all of these to become a full member. Once you do, visit https://{Settings().http.domain}/profile to re-run this check.
-
-If you think you have completed all of these, please reach out to an Exec on the Hack@UCF Discord.
-
-We hope to see you soon,
- - Hack@UCF Bot
-"""
- Discord.send_message(discord_id, fail_msg)
-
- else:
- logger.info("\tDid not pay dues yet.")
-
- return False
diff --git a/util/forms.py b/util/forms.py
deleted file mode 100644
index a51f552..0000000
--- a/util/forms.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import json
-import os
-
-
-class Forms:
- def get_form_body(file="1"):
- #if file.contains("..", "\\"):
- # raise ValueError("Invalid file name")
- try:
- form_file = os.path.join(os.getcwd(), "forms", f"{file}.json")
- return json.load(open(form_file, "r"))
- except FileNotFoundError:
- raise