# ows-delivery-metadata

Orchard web service generating metadata formats used for content delivery.

## Setup

1. Fork project on [GitHub](https://github.com/theorchard/ows-delivery-metadata)

2. Checkout application source code
```sh
$ git clone git@github.com:$GITHUB_USERNAME/ows-delivery-metadata.git
$ cd ows-delivery-metadata
```

2. Config variables
```sh
$ cp .env.shadow .env
```
* Fill in values for connections in `.env`

3. Grant AWS access

```sh
$ awsume prod
```
Needed for:
* Communication with downstream `QA` applications
* Access to pull down base parent docker image

4. Grant Supply Chain QA AWS Access

Follow the prompts to configure a profile using the role named `content-delivery-role` in the Supply Chain QA account (**311141540202**) see this [guide](https://www.notion.so/AWS-Access-f841b9dd815d4443a80e96a86c92cd2f?pvs=4#2796c2b2b6a349d2b193941db9165932) for details.

```sh
$ awsume content-delivery-role
```
Needed for:
* Communication with `qa-ows-timed-release` when running the app locally because the M2M JWT secret passed along with the request is in the Supply Chain QA account, not the Prod account.


## Usage

### Docker

Run the service with hot-reloading:

```sh
$ docker compose up --build --remove-orphans dev
$ curl http://localhost:8888/hello/
```

Run the service like it would run in deployed environment:

```sh
$ docker compose up --build --remove-orphans deploy
$ curl http://localhost:8889/hello/
```

Run linting and unit tests:
```sh
$ docker compose run --build --rm unit-lint
```

Run integration tests:

```sh
$ docker compose run --build --rm integration
```

Attach to breakpoint:

Find container ID
```sh
$ docker ps | grep $container_name
```

Attach
```sh
$ docker exec -it $container_id bash
```

## Update Poetry Lockfile

All packages:
```sh
$ docker compose run --build --rm update-lockfile
```

Single package:
```sh
$ docker compose run --build --rm update-lockfile $PACKAGE_NAME
```

## Format Code

```sh
$ docker compose run --build --rm format
```

## Make Requests

See [docs](https://qa-ows-delivery-metadata.theorchard.io/docs) for usage instructions.

:bulb: Try `format=raw&version=latest` for a JSON response

## Code Patterns

The goal is to standardize patterns and separate concerns so we can easily maintain and debug store specific logic as the application grows and to prevent common performance problems.

### Request Flow Layers

As a request moves through the application, it will be processed by several layers that have distinct responsibilities.

* `api`
	* Validate and route incoming request
	* Determine HTTP response code for outgoing request
* `delivery metadata model`
	* Load data from various outside sources using `clients` which implement `connectors`
	* Validate loaded data and produce a strongly typed result object
	* No formatting (ex. DDEX) logic, only "Orchard" logic that applies to all stores
* `delivery rights model`
    * Build an intelligent "timeline" of rights intervals with territories and other rights information attached to each interval
    * No actual formatting logic and no format-specific nomenclature; just organizing all data points into rights intervals based on start and end dates
* `formatter`
	* Reflect data from the `model` layer to an output that a store wants
	* No loading data from outside sources
	* No modifying result object from `model` layer
	* No logic branching based on `store_id` (see "Store Level Settings" below)

### Store Level Settings

There are two ways to customize how a store's metadata generation behavior can diverge from the default formatter. Settings could be migrated between the two systems as requirements grow.

#### 1. Store Attribute

##### When To Use

1. Ops could be able to toggle this setting at their own discretion
2. Setting controls network requests that need to happen before the formatter runs
3. Exact setting behavior is needed for a large number of stores


##### Adding New One

1. Add new attribute in `delivery_metadata.models.schema.Store` with a default value
2. Configure store override of default value in `delivery_metadata.constants.store_setting_overrides`

:exclamation: Eventually the plan is to store this data in a database and not need to update code to change overrides and allow for users to toggle settings.

#### 2. Store Formatter Subclass

##### When To Use

1. Does not fit into any of the "Store Attribute" reasons above
2. Custom behavior tailored to one or only a few stores

:exclamation: Any "Store Formatter Subclass" setting _could_ be implemented as a "Store Attribute" setting. The idea in having this pattern is to provide a logical break between simple "toggles" and more complex and custom implementations and to avoid having an overwhelming number of settings that grow out of control.

##### Adding New One

1. Navigate to `delivery_metadata/logic/metadata_formatters/`.
2. If the store uses DDEX ERN as the standard for delivery metadata:
    * Navigate one more level down to `ddex/`
    * If the DDEX ERN version you're looking to use is already present (for now, only `/ddex_ern_4_3` or DDEX ERN 4.3 is available) navigate to that directory. Otherwise, please reach out to the Content Delivery and Asset Management team in #content-delivery-and-asset-mgmt to request a new DDEX ERN version.
    * Create a new directory named after the store (i.e. `deezer/`) and navigate to it.
    * Create a new file in the following format: `{store-name}_formatter.py`. Example: `deezer_formatter.py`.
    * In your newly created file, create a class with the following naming convention: `StorenameDdexErn43Formatter` and extend this class from `DdexErn43Formatter`. Example: `DeezerDdexErn43Formatter(DdexErn43Formatter)`. If the store name has multiple words, please use camel-casing for each word.
    * Create product type specific classes for which the store supports named in this format `{store-name}_formatter_audio.py`. Example: `deezer_formatter_audio.py`.
    * In your newly created file, create a class called `{store-name}DdexErn43FormatterAudio`.  Example `DeezerDdexErn43FormatterAudio`.
    * The product type specific classes should extend from the store formatter class as well as the default product type specific classes.  Example `class DeezerDdexErn43FormatterAudio(DdexErn43FormatterAudio, DeezerDdexErn43Formatter)`.
    * In case a method is implemented in more than one parent classes, calling `super().method()` will pick the first (leftmost) class's definition.
    * To call a specific parent class's method, specify which class `super()` should use.  In this case, the sequence of multiple inheritance doesn't matter.  Example: `DdexErn43FormatterAudio.method(self, ...)` or `DeezerDdexErn43Formatter.method(self, ...)`.
    * Now, put it all together:
      * `{store-name}_formatter.py` - this is the store's base formatter module of the store that should extend the base DDEX (or other formats) class in the base DDEX formatter module.
      ```
      # deezer_formatter.py
      from delivery_metadata.logic.metadata_formatters.ddex.ddex_ern_4_3.ddex_ern_4_3_formatter import (
          DdexErn43Formatter,
      )
  
     
      class DeezerDdexErn43Formatter(DdexErn43Formatter):
          """Deezer-specific DDEX ERN 4.3 formatter."""
      ```
      * `{store-name}_formatter_audio.py` - this is the store's audio formatter module that should extend the store's base formatter class in the store formatter base module as well as the base DDEX (or other formats) audio formatter class in the base audio formatter module.
      ```
      # deezer_formatter_audio.py
      from delivery_metadata.logic.metadata_formatters.ddex.ddex_ern_4_3.formatter_audio import (
          DdexErn43FormatterAudio,
      )
      from delivery_metadata.logic.metadata_formatters.ddex.ddex_ern_4_3.deezer.deezer_formatter_audio import (
          DeezerDdexErn43FormatterAudio
      )
      
      
      class DeezerDdexErn43FormatterAudio(DdexErn43FormatterAudio, DeezerDdexErn43Formatter):
          """Deezer specific DDEX ERN 4.3 audio formatter."""
      ```
      * `{store-name}_formatter_video.py` - this is the store's video formatter module that should extend the store's base formatter class in the store formatter base module as well as the base DDEX (or other formats) video formatter class in the base video formatter module.
      ```
      # deezer_formatter_video.py
      from delivery_metadata.logic.metadata_formatters.ddex.ddex_ern_4_3.formatter_video import (
          DdexErn43FormatterVideo,
      )
      from delivery_metadata.logic.metadata_formatters.ddex.ddex_ern_4_3.deezer.deezer_formatter_video import (
          DeezerDdexErn43FormatterVideo
      )
      
      
      class DeezerDdexErn43FormatterVideo(DdexErn43FormatterVideo, DeezerDdexErn43Formatter):
          """Deezer specific DDEX ERN 4.3 video formatter."""
      ```
      * `{store-name}_formatter_bundle.py` - this is the store's bundle formatter module that should extend the store's base formatter class in the store formatter base module as well as the base DDEX (or other formats) bundle formatter class in the base video formatter module.
      ```
      # deezer_formatter_bundle.py
      from delivery_metadata.logic.metadata_formatters.ddex.ddex_ern_4_3.formatter_bundle import (
          DdexErn43FormatterBundle,
      )
      from delivery_metadata.logic.metadata_formatters.ddex.ddex_ern_4_3.deezer.deezer_formatter_bundle import (
          DeezerDdexErn43FormatterBundle
      )
      
      
      class DeezerDdexErn43FormatterBundle(DdexErn43FormatterBundle, DeezerDdexErn43Formatter):
          """Deezer specific DDEX ERN 4.3 bundle formatter."""
      ```
    * Be very careful with multiple inheritance when calling a member method within a method that overrides the parent method.  In this scenario, calling `self` will pick the closest (left-most) class's definition.
      * For instance,
      ```
      class Parent():
          def a(self): ...

                  
      class Child1(Parent):
          def a(self):
              self.b()
      
          def b(self): ...
      
      
      class Child2(Parent):
          def a(self):
              self.b()
      
          def b(self): ...
      
      
      class GrandChild(Child1, Child2):
          def c(self):
              self.a() # tihs will invoke Child1.b()
              Child1.a() # this will invoke Child1.b()
              Child2.a() # this will invoke Child1.b() as well
      ```
      * In order to have the expected invocation of parent method, the parent method that uses `self` needs to be updated to use the class name to invoke. See example below:
      ```
      class Parent():
          def a(self): ...

                  
      class Child1(Parent):
          def a(self):
              Child1.b(self)
      
          def b(self): ...
      
      
      class Child2(Parent):
          def a(self):
              Child2.b(self)
      
          def b(self): ...
      
      
      class GrandChild(Child1, Child2):
          def c(self):
              self.a() # tihs will invoke Child1.b()
              Child1.a() # this will invoke Child1.b()
              Child2.a() # this will now invoke Child2.b()
      ```
    * Configure the custom formatter to be used by this store
        * Add a new dictionary in `store_delivery_format_version_to_metadata_formatter` in `delivery_metadata/logic/delivery_metadata.py` in the `DeliveryFormatVersion.DDEX_ERN_4_3` section.
        ```
        DeliveryFormatVersion.DDEX_ERN_4_3: {
            ...
            StoreIds.DEEZER: {
                MetadataFormatType.AUDIO: SpotifyDdexErn43FormatterAudio,
            },
        },
        ```
        * Configure the new constant to map to the new formatter in `store_delivery_format_version` at `delivery_metadata/logic.delivery_metadata.py`
3. If the store uses a different standard for delivery metadata e.g. iTunes Music Store Package (iTMSP), please reach out to the Content Delivery and Asset Management team in #content-delivery-and-asset-mgmt to request a new metadata standard.

### Unit Test Best Practices

Many tests rely on the `delivery_metadata_mock` fixture. Follow these guidelines.
1. Avoid comparing the entire `DeliveryMetadata` object (or any large data set) with an expected result, only check the data your test cares about. If you do need to check large objects or XML, see the "Snapshots" section below
2. Use `update_mock_object` in `tests/unit/conftest.py` to generate a modified temporary instance of `DeliveryMetadata` for your test
3. Only update `delivery_metadata_mock` when new **non-nullable** attributes are added

#### Snapshots

1. Add `snapshot: SnapshotAssertion` as a parameter in your test function with an assertion
```python
from syrupy.assertion import SnapshotAssertion

def test_generate_resource_list_spotify(
    delivery_metadata_mock: DeliveryMetadata,
    audio_delivery_rights_mock_no_pricing: DeliveryRights,
    snapshot: SnapshotAssertion,
) -> None:
	...
    assert result == snapshot
```

2. Run tests in snapshot generation mode
```bash
docker compose run --build --rm update-snapshots
```

3. Debug failing snapshot tests
```bash
docker compose run --build --rm -e TEST_ARGS="-vv" unit-lint
```

reference: [syrupy](https://github.com/syrupy-project/syrupy)
