Skip to content

Commit

Permalink
Merge pull request #1617 from ChildMindInstitute/release/1.7.0
Browse files Browse the repository at this point in the history
Release/1.7.0
  • Loading branch information
ChaconC authored Oct 2, 2024
2 parents 86db287 + 2abf020 commit e1e003e
Show file tree
Hide file tree
Showing 17 changed files with 692 additions and 14 deletions.
6 changes: 3 additions & 3 deletions .env.default
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ REDIS__HOST=redis
# Application configurations

# CORS
CORS__ALLOW_ORIGINS=*
#CORS__ALLOW_ORIGINS_REGEX=
CORS__ALLOW_ORIGINS=https://localhost
CORS__ALLOW_ORIGIN_REGEX=https?://localhost:\d+
CORS__ALLOW_CREDENTIALS=true
CORS__ALLOW_METHODS=*
CORS__ALLOW_HEADERS=*
Expand All @@ -40,7 +40,7 @@ AUTHENTICATION__REFRESH_TOKEN__TRANSITION_KEY=
# Mailing
MAILING__MAIL__USERNAME=mailhog
MAILING__MAIL__PASSWORD=mailhog
MAILING__MAIL__SERVER=fcm.mail.server
MAILING__MAIL__SERVER=mailhog
MAILING__MAIL__PORT=1025
MAILING__MAIL_STARTTLS=
MAILING__MAIL_SSL_TLS=
Expand Down
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ For manual installation refer to each service's documentation:

Pipenv used as a default dependencies manager
Create your virtual environment:

> **NOTE:**
> Pipenv used as a default dependencies manager.
> When developing on the API be sure to work from within an active shell.
> If using VScode, open the terminal from w/in the active shell. Ideally, avoid using the integrated terminal during this process.
```bash
# Activate your environment
pipenv shell
Expand All @@ -197,7 +203,7 @@ Install all dependencies
```bash
# Install all deps from Pipfile.lock
# to install venv to current directory use `export PIPENV_VENV_IN_PROJECT=1`
pipenv sync --dev
pipenv sync --dev --system
```

> 🛑 **NOTE:** if you don't use `pipenv` for some reason remember that you will not have automatically exported variables from your `.env` file.
Expand Down Expand Up @@ -233,24 +239,41 @@ pipenv install greenlet
alembic upgrade head
```

> 🛑 **NOTE:** If you run into role based errors e.g. `role "postgres" does not exist`, check to see if that program is running anywhere else (e.g. Homebrew), run... `ps -ef | grep {program-that-errored}`
> You can attempt to kill the process with the following command `kill -9 {PID-to-program-that-errored}`, followed by rerunning the previous check to confirm if the program has stopped.
## Running the app

### Running locally

This option allows you to run the app for development purposes without having to manually build the Docker image.
This option allows you to run the app for development purposes without having to manually build the Docker image (i.e. When developing on the Web or Admin project).

- Make sure all [required services](#required-services) are properly setup
- If you're running required services using Docker, disable the `app` service from `docker-compose` before running:
```bash
docker-compose up -d
```

Alternatively, you may run these services using [make](#running-using-makefile):
Alternatively, you may run these services using [make](#running-using-makefile) (i.e. When developing the API):

- You'll need to sudo into `/etc/hosts` and append the following changes.

```
#mindlogger
127.0.0.1 postgres
127.0.0.1 rabbitmq
127.0.0.1 redis
127.0.0.1 mailhog
```

Then run the following command from within the active virtual environment shell...

```bash
make run_local
```

> 🛑 **NOTE:** Don't forget to set the `PYTHONPATH` environment variable, e.g: export PYTHONPATH=src/
- To test that the API is up and running navigate to `http://localhost:8000/docs` in a browser.

In project we use simplified version of imports: `from apps.application_name import class_name, function_name, module_nanme`.

Expand Down
34 changes: 33 additions & 1 deletion src/apps/activities/domain/custom_validation_subscale.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from apps.activities.errors import InvalidRawScoreSubscaleError, InvalidScoreSubscaleError
from pydantic import PositiveInt, ValidationError

from apps.activities.errors import InvalidAgeSubscaleError, InvalidRawScoreSubscaleError, InvalidScoreSubscaleError


def validate_score_subscale_table(value: str):
Expand Down Expand Up @@ -30,6 +32,36 @@ def validate_score_subscale_table(value: str):
return value


def validate_age_subscale(value: PositiveInt | str | None):
# make sure its format is "x~y" or "x"

def validate_non_negative_int(maybe_int: str):
try:
int_value = int(maybe_int)
if int_value < 0:
raise InvalidAgeSubscaleError()
except (ValueError, ValidationError):
raise InvalidAgeSubscaleError()

if value is None:
return value
elif isinstance(value, int):
if value < 0:
raise InvalidAgeSubscaleError()
elif "~" not in value:
# make sure value is a positive integer
validate_non_negative_int(value)
else:
# make sure x and y are positive integers
x: str
y: str
x, y = value.split("~")
validate_non_negative_int(x)
validate_non_negative_int(y)

return value


def validate_raw_score_subscale(value: str):
# make sure it's format is "x~y" or "x"
if "~" not in value:
Expand Down
12 changes: 10 additions & 2 deletions src/apps/activities/domain/scores_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@

from apps.activities.domain.conditional_logic import Match
from apps.activities.domain.conditions import ScoreCondition, SectionCondition
from apps.activities.domain.custom_validation_subscale import validate_raw_score_subscale, validate_score_subscale_table
from apps.activities.domain.custom_validation_subscale import (
validate_age_subscale,
validate_raw_score_subscale,
validate_score_subscale_table,
)
from apps.activities.errors import (
DuplicateScoreConditionIdError,
DuplicateScoreConditionNameError,
Expand Down Expand Up @@ -168,7 +172,7 @@ class SubscaleCalculationType(str, Enum):
class SubScaleLookupTable(PublicModel):
score: str
raw_score: str
age: PositiveInt | None = None
age: PositiveInt | str | None = None
sex: str | None = Field(default=None, regex="^(M|F)$", description="M or F")
optional_text: str | None = None
severity: str | None = Field(default=None, regex="^(Minimal|Mild|Moderate|Severe)$")
Expand All @@ -181,6 +185,10 @@ def validate_raw_score_lookup(cls, value):
def validate_score_lookup(cls, value):
return validate_score_subscale_table(value)

@validator("age")
def validate_age_lookup(cls, value):
return validate_age_subscale(value)


class SubscaleItemType(str, Enum):
ITEM = "item"
Expand Down
4 changes: 4 additions & 0 deletions src/apps/activities/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,10 @@ class InvalidScoreSubscaleError(ValidationError):
message = _("Score in subscale lookup table is invalid.")


class InvalidAgeSubscaleError(ValidationError):
message = _("Age in subscale lookup table is invalid.")


class IncorrectSubscaleItemError(ValidationError):
message = _("Activity item inside subscale does not exist.")

Expand Down
8 changes: 6 additions & 2 deletions src/apps/activities/tests/fixtures/scores_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ def subscale_total_score_table() -> list[TotalScoreTable]:
@pytest.fixture
def subscale_lookup_table() -> list[SubScaleLookupTable]:
return [
SubScaleLookupTable(score="10", age=10, sex="M", raw_score="1", optional_text="some url", severity="Minimal"),
SubScaleLookupTable(score="20", age=10, sex="F", raw_score="2", optional_text="some url", severity="Mild"),
SubScaleLookupTable(score="10", age="10", sex="M", raw_score="1", optional_text="some url", severity="Minimal"),
SubScaleLookupTable(score="20", age="10", sex="F", raw_score="2", optional_text="some url", severity="Mild"),
SubScaleLookupTable(score="20", age=15, sex="F", raw_score="2", optional_text="some url", severity="Mild"),
SubScaleLookupTable(score="20", sex="F", raw_score="2", optional_text="some url", severity="Mild"),
SubScaleLookupTable(score="20", age="10~15", sex="F", raw_score="2", optional_text="some url", severity="Mild"),
SubScaleLookupTable(score="20", age="0~5", sex="F", raw_score="2", optional_text="some url", severity="Mild"),
]
50 changes: 50 additions & 0 deletions src/apps/activity_assignments/crud/assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,44 @@ async def already_exists(self, schema: ActivityAssigmentSchema) -> ActivityAssig
db_result = await self._execute(query)
return db_result.scalars().first()

async def get_target_subject_ids_by_activity_or_flow_ids(
self,
respondent_subject_id: uuid.UUID,
activity_or_flow_ids: list[uuid.UUID] = [],
) -> list[uuid.UUID]:
"""
Retrieves the IDs of target subjects that have assignments to be completed by the provided respondent.
Parameters:
----------
respondent_subject_id : uuid.UUID
The ID of the respondent subject to search for. This parameter is required.
activity_or_flow_ids : list[uuid.UUID]
Optional list of activity or flow IDs to narrow the search. These IDs may correspond to either
`activity_id` or `activity_flow_id` fields
Returns:
-------
list[uuid.UUID]
List of target subject IDs associated with the provided activity or flow IDs.
"""
query = select(ActivityAssigmentSchema.target_subject_id).where(
ActivityAssigmentSchema.respondent_subject_id == respondent_subject_id,
ActivityAssigmentSchema.soft_exists(),
)

if len(activity_or_flow_ids) > 0:
query = query.where(
or_(
ActivityAssigmentSchema.activity_id.in_(activity_or_flow_ids),
ActivityAssigmentSchema.activity_flow_id.in_(activity_or_flow_ids),
)
)

db_result = await self._execute(query.distinct())

return db_result.scalars().all()

async def delete_by_activity_or_flow_ids(self, activity_or_flow_ids: list[uuid.UUID]):
"""
Marks the `is_deleted` field as True for all matching assignments based on the provided
Expand Down Expand Up @@ -263,3 +301,15 @@ async def upsert(self, values: dict) -> ActivityAssigmentSchema | None:
updated_schema = await self._get("id", model_id)

return updated_schema

async def check_if_auto_assigned(self, activity_or_flow_id: uuid.UUID) -> bool | None:
"""
Checks if the activity or flow is currently set to auto-assign.
"""
activities_query = select(ActivitySchema.auto_assign).where(ActivitySchema.id == activity_or_flow_id)
flows_query = select(ActivityFlowSchema.auto_assign).where(ActivityFlowSchema.id == activity_or_flow_id)

union_query = activities_query.union_all(flows_query).limit(1)

db_result = await self._execute(union_query)
return db_result.scalar_one_or_none()
31 changes: 31 additions & 0 deletions src/apps/activity_assignments/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,37 @@ async def get_all_with_subject_entities(
for assignment in assignments
]

async def get_target_subject_ids_by_respondent(
self,
respondent_subject_id: uuid.UUID,
activity_or_flow_ids: list[uuid.UUID] = [],
) -> list[uuid.UUID]:
"""
Retrieves the IDs of target subjects that have assignments to be completed by the provided respondent.
Parameters:
----------
respondent_subject_id : uuid.UUID
The ID of the respondent subject to search for. This parameter is required.
activity_or_flow_ids : list[uuid.UUID]
Optional list of activity or flow IDs to narrow the search. These IDs may correspond to either
`activity_id` or `activity_flow_id` fields
Returns:
-------
list[uuid.UUID]
List of target subject IDs associated with the provided activity or flow IDs.
"""
return await ActivityAssigmentCRUD(self.session).get_target_subject_ids_by_activity_or_flow_ids(
respondent_subject_id, activity_or_flow_ids
)

async def check_if_auto_assigned(self, activity_or_flow_id: uuid.UUID) -> bool | None:
"""
Checks if the activity or flow is currently set to auto-assign.
"""
return await ActivityAssigmentCRUD(self.session).check_if_auto_assigned(activity_or_flow_id)

@staticmethod
def _get_email_template_name(language: str) -> str:
return f"new_activity_assignments_{language}"
Expand Down
21 changes: 21 additions & 0 deletions src/apps/answers/crud/answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -937,3 +937,24 @@ async def delete_by_ids(self, ids: list[uuid.UUID]):
query: Query = delete(AnswerSchema)
query = query.where(AnswerSchema.id.in_(ids))
await self._execute(query)

async def get_target_subject_ids_by_respondent(
self, respondent_subject_id: uuid.UUID, activity_or_flow_id: uuid.UUID
):
query: Query = (
select(
AnswerSchema.target_subject_id,
func.count(func.distinct(AnswerSchema.submit_id)).label("submission_count"),
)
.where(
AnswerSchema.source_subject_id == respondent_subject_id,
or_(
AnswerSchema.id_from_history_id(AnswerSchema.activity_history_id) == str(activity_or_flow_id),
AnswerSchema.id_from_history_id(AnswerSchema.flow_history_id) == str(activity_or_flow_id),
),
)
.group_by(AnswerSchema.target_subject_id)
)

res = await self._execute(query)
return res.all()
7 changes: 7 additions & 0 deletions src/apps/answers/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1853,6 +1853,13 @@ async def _prepare_answer_reviews(
)
return results

async def get_target_subject_ids_by_respondent_and_activity_or_flow(
self, respondent_subject_id: uuid.UUID, activity_or_flow_id: uuid.UUID
) -> list[tuple[uuid.UUID, int]]:
return await AnswersCRUD(self.answer_session).get_target_subject_ids_by_respondent(
respondent_subject_id, activity_or_flow_id
)


class ReportServerService:
def __init__(self, session, arbitrary_session=None):
Expand Down
26 changes: 26 additions & 0 deletions src/apps/applets/tests/test_applet_activity_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
SubscaleSetting,
TotalScoreTable,
)
from apps.activities.errors import InvalidAgeSubscaleError
from apps.applets.domain.applet_create_update import AppletCreate, AppletUpdate
from apps.applets.domain.applet_full import AppletFull
from apps.shared.enums import Language
Expand Down Expand Up @@ -521,6 +522,31 @@ async def test_create_applet__activity_with_subscale_settings_with_subscale_look
result = resp.json()["result"]
assert result["activities"][0]["subscaleSetting"] == sub_setting.dict(by_alias=True)

async def test_create_applet__activity_with_subscale_settings_with_invalid_subscale_lookup_table_age(
self,
client: TestClient,
applet_minimal_data: AppletCreate,
single_select_item_create_with_score: ActivityItemCreate,
tom: User,
subscale_setting: SubscaleSetting,
):
client.login(tom)
data = applet_minimal_data.copy(deep=True).dict()
sub_setting = subscale_setting.copy(deep=True).dict()
sub_setting["subscales"][0]["items"][0]["name"] = single_select_item_create_with_score.name
data["activities"][0]["items"] = [single_select_item_create_with_score]
data["activities"][0]["subscale_setting"] = sub_setting

invalid_ages = ["-1", "-1~10", "~", "x~y", "1~", "~10"]
for age in invalid_ages:
sub_setting["subscales"][0]["subscale_table_data"] = [
dict(score="20", age=age, sex="F", raw_score="2", optional_text="some url", severity="Mild")
]
resp = await client.post(self.applet_create_url.format(owner_id=tom.id), data=data)
assert resp.status_code == http.HTTPStatus.BAD_REQUEST
result = resp.json()["result"]
assert result[0]["message"] == InvalidAgeSubscaleError.message

async def test_create_applet__activity_with_score_and_reports__score_and_section(
self,
client: TestClient,
Expand Down
Loading

0 comments on commit e1e003e

Please sign in to comment.