├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── api_v1 │ │ ├── __init__.py │ │ ├── api.py │ │ └── endpoints │ │ │ ├── cases.py │ │ │ └── login.py │ ├── graph_ql │ │ ├── resolvers │ │ │ ├── user_resolvers.py │ │ │ ├── court_resolvers.py │ │ │ ├── dev_helpers.py │ │ │ ├── __init__.py │ │ │ ├── record_on_appeal_resolvers.py │ │ │ ├── case_resolvers.py │ │ │ ├── query_resolvers.py │ │ │ └── mutation_resolvers.py │ │ ├── schema.py │ │ ├── scalars.py │ │ ├── routes_public.py │ │ ├── __init__.py │ │ ├── routes_private.py │ │ └── schemas │ │ │ └── case.graphql │ └── dependency.py ├── core │ ├── __init__.py │ ├── enums.py │ ├── security.py │ ├── config.py │ └── courts.py ├── docs │ ├── __init__.py │ └── api_metadata.py ├── tests │ ├── __init__.py │ ├── integration │ │ ├── __init__.py │ │ ├── test_auth.py │ │ ├── test_graphql.py │ │ ├── test_create_roa.py │ │ └── conftest.py │ ├── pytest.ini │ └── unit │ │ ├── test_case_entity.py │ │ ├── conftest.py │ │ ├── test_case_resolvers.py │ │ ├── test_graphql_queries.py │ │ ├── test_court_resolvers.py │ │ └── test_roa_entity.py ├── entities │ ├── role.py │ ├── token.py │ ├── docket_entry.py │ ├── __init__.py │ ├── case_entity.py │ ├── user.py │ ├── court.py │ ├── record_on_appeal.py │ └── case.py ├── data │ ├── __init__.py │ ├── role │ │ ├── role.py │ │ └── role_repo.py │ ├── database.py │ ├── case │ │ ├── case_repo.py │ │ └── case.py │ ├── user │ │ ├── user_repo.py │ │ └── user.py │ ├── record_on_appeal │ │ ├── record_on_appeal_repo.py │ │ └── record_on_appeal.py │ ├── dev_helpers.py │ └── init_db.py └── main.py ├── Procfile ├── runtime.txt ├── .cfignore ├── bin └── run.sh ├── .flake8 ├── manifest.yml ├── requirements.txt ├── .gitignore ├── seed_test_data.py ├── alembic ├── script.py.mako ├── versions │ ├── 400c04778408_added_status_field_to_case.py │ ├── a966106daa49_added_court_field_to_case.py │ ├── 878d3f26cbfd_add_court_id_to_user_table.py │ ├── 7426da0d030a_add_roles.py │ ├── 5c02d93ce254_add_court_to_docket_entry_table_in_case_.py │ ├── 2c9cf71649e2_add_tables_for_roa.py │ └── 179a279f5647_init_db.py ├── README.md └── env.py ├── .github └── workflows │ ├── run-api-tests-action.yml │ ├── api-test-deploy.yml │ └── codeql-analysis.yml ├── CONTRIBUTING.md ├── LICENSE.md ├── doc └── adr │ ├── 0001-cloud-dot-gov.md │ ├── 0003-python.md │ └── 0002-graphql.md ├── alembic.ini ├── README.md └── seed_data └── case.json /app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bin/run.sh -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/docs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.8.x -------------------------------------------------------------------------------- /app/api/api_v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = ./ -------------------------------------------------------------------------------- /.cfignore: -------------------------------------------------------------------------------- 1 | requirements_dev.txt 2 | *.ipynb 3 | .ipynb_checkpoints 4 | .env 5 | .venv -------------------------------------------------------------------------------- /app/entities/role.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Role(): 6 | id: int 7 | rolename: str 8 | -------------------------------------------------------------------------------- /bin/run.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | alembic upgrade head 6 | gunicorn app.main:app -w 2 -k uvicorn.workers.UvicornWorker -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | exclude = .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache 4 | per-file-ignores = 5 | # line too long 6 | seed_data/cases.py: E501, -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | applications: 3 | - name: ao-backend 4 | random-route: true 5 | buildpacks: 6 | - python_buildpack 7 | memory: 256M 8 | env: 9 | PROJECT_NAME: AOBackend 10 | services: 11 | - ao_sandbox 12 | - ao_api_creds -------------------------------------------------------------------------------- /app/entities/token.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from pydantic import BaseModel 3 | 4 | 5 | class Token(BaseModel): 6 | access_token: str 7 | token_type: str 8 | 9 | 10 | class TokenPayload(BaseModel): 11 | sub: Optional[int] = None 12 | -------------------------------------------------------------------------------- /app/api/api_v1/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api.api_v1.endpoints import cases, login 4 | 5 | api_router = APIRouter() 6 | 7 | api_router.include_router(login.router, tags=["login"]) 8 | api_router.include_router(cases.router, prefix="/cases", tags=["cases"]) 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.65.2 2 | gunicorn==20.1.0 3 | uvicorn[standard] 4 | python-multipart 5 | python-jose[cryptography] 6 | passlib[bcrypt] 7 | SQLAlchemy==1.4.20 8 | pydantic[email] 9 | ariadne==0.13.0 10 | alembic==1.6.5 11 | psycopg2==2.9.1 12 | flake8==3.9.2 13 | requests==2.26.0 14 | pytest -------------------------------------------------------------------------------- /app/data/__init__.py: -------------------------------------------------------------------------------- 1 | from .user.user_repo import user 2 | from .role.role_repo import role 3 | from .case.case_repo import case 4 | from .record_on_appeal.record_on_appeal_repo import record_on_appeal, record_on_appeal_docket_entry 5 | from .database import get_db, SessionLocal, mapper_registry 6 | from .user.user import run_mappers 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Environments 7 | .env 8 | .venv 9 | env/ 10 | venv/ 11 | ENV/ 12 | env.bak/ 13 | venv.bak/ 14 | 15 | # Data like sqlite 16 | *.db 17 | 18 | # iPython Notebooks 19 | *.ipynb 20 | .ipynb_checkpoints 21 | 22 | # pytest 23 | .pytest_cache 24 | 25 | -------------------------------------------------------------------------------- /app/api/graph_ql/resolvers/user_resolvers.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from ariadne import ObjectType 3 | from app.entities import User, Court 4 | 5 | user = ObjectType("User") 6 | 7 | 8 | @user.field("court") 9 | def resolve_courts(obj: User, *_) -> Optional[Court]: 10 | '''Given a user, resolve the court''' 11 | if obj.court_id: 12 | return Court.from_id(obj.court_id) 13 | -------------------------------------------------------------------------------- /app/api/graph_ql/resolvers/court_resolvers.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from ariadne import ObjectType 3 | from app.entities import Court 4 | 5 | court = ObjectType("Court") 6 | 7 | 8 | @court.field("lowerCourts") 9 | def resolve_lower_courts(obj: Court, *_) -> List[Court]: 10 | '''Given a court, find all the courts that list this as it's parent''' 11 | return Court.from_id(obj.id).lower_courts() 12 | -------------------------------------------------------------------------------- /app/entities/docket_entry.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from dataclasses import dataclass, replace 3 | 4 | from .case_entity import CaseEntity 5 | 6 | 7 | @dataclass 8 | class DocketEntry(CaseEntity): 9 | case_id: int 10 | sequence_no: int 11 | text: str 12 | date_filed: datetime.datetime 13 | entry_type: str 14 | sealed: bool = False 15 | 16 | def copy(self): 17 | return replace(self) 18 | -------------------------------------------------------------------------------- /app/api/graph_ql/resolvers/dev_helpers.py: -------------------------------------------------------------------------------- 1 | from ariadne import MutationType 2 | from app.data.dev_helpers import case_dev_util 3 | 4 | dev_mutation = MutationType() 5 | 6 | 7 | @dev_mutation.field("resetSeedData") 8 | def reset_seed_data(obj, info): 9 | print('resetting data') 10 | session = info.context['request'].state.db 11 | case_dev_util.delete_all(session) 12 | case_dev_util.add_seed_cases(session) 13 | return True 14 | -------------------------------------------------------------------------------- /app/data/role/role.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, Table 2 | 3 | from ..database import mapper_registry 4 | from app.entities import Role 5 | 6 | role_table = Table( 7 | "roles", 8 | mapper_registry.metadata, 9 | Column('id', Integer, primary_key=True, index=True), 10 | Column('rolename', String, unique=True, index=True) 11 | ) 12 | 13 | 14 | def run_mappers(): 15 | mapper_registry.map_imperatively(Role, role_table) 16 | -------------------------------------------------------------------------------- /app/entities/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Pydantic types are defined in this package. These are used for validating and 3 | transforming objects into known formats. 4 | ''' 5 | 6 | from .token import Token, TokenPayload 7 | from .user import User, PublicUser 8 | from .role import Role 9 | from .case import Case, DistrictCase, AppellateCase, CaseType 10 | from .docket_entry import DocketEntry 11 | from .court import Court 12 | from .record_on_appeal import RecordOnAppeal, RecordOnAppealDocketEntry -------------------------------------------------------------------------------- /app/entities/case_entity.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from dataclasses import dataclass, field 3 | 4 | 5 | @dataclass 6 | class CaseEntity(): 7 | ''' 8 | This is a base class for almost records and cases including Cases, ROAs, 9 | and DocketEntries. All will need an id, an owning court, and date tracking. 10 | ''' 11 | id: int = field(init=False) 12 | court: str 13 | created_at: datetime = field(init=False) 14 | updated_on: datetime = field(init=False) 15 | -------------------------------------------------------------------------------- /app/tests/unit/test_case_entity.py: -------------------------------------------------------------------------------- 1 | from app.entities import AppellateCase 2 | from app.core.enums import CourtType 3 | 4 | 5 | def test_appeal_from_district_case(simple_case) -> None: 6 | ''' 7 | It should create an appellate case, set the original_case_id and 8 | change the original case status. 9 | ''' 10 | appeal = AppellateCase.from_district_case(simple_case, 'ca9') 11 | assert appeal.type == CourtType.appellate 12 | assert appeal.original_case_id == simple_case.id 13 | -------------------------------------------------------------------------------- /app/api/graph_ql/schema.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from ariadne import ( 4 | load_schema_from_path, 5 | make_executable_schema, 6 | snake_case_fallback_resolvers 7 | ) 8 | from .resolvers import resolvers 9 | from .scalars import datetime_scalar 10 | 11 | 12 | dirname = os.path.dirname(__file__) 13 | schema_dir = os.path.join(dirname, 'schemas/') 14 | type_defs = load_schema_from_path(schema_dir) 15 | 16 | schema = make_executable_schema(type_defs, *resolvers, datetime_scalar, snake_case_fallback_resolvers) 17 | -------------------------------------------------------------------------------- /seed_test_data.py: -------------------------------------------------------------------------------- 1 | '''Seed the database with test data''' 2 | import json 3 | 4 | from app.entities import DistrictCase, DocketEntry 5 | from app.data.database import SessionLocal 6 | 7 | db = SessionLocal() 8 | 9 | CASE_DATA_PATH = "./seed_data/case.json" 10 | 11 | with open(CASE_DATA_PATH, 'r') as case_file: 12 | cases = json.load(case_file) 13 | for case in cases: 14 | case['docket_entries'] = [DocketEntry(**d, court=case['court']) for d in case['docket_entries']] 15 | db.add(DistrictCase(**case)) 16 | db.commit() 17 | -------------------------------------------------------------------------------- /app/api/graph_ql/scalars.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from ariadne import ScalarType 4 | 5 | 6 | def serialize_datetime(value: datetime) -> str: 7 | date = value 8 | if format: 9 | return date.strftime("%Y-%m-%d") 10 | else: 11 | return date.isoformat() 12 | 13 | 14 | def parse_datetime_value(value: str) -> datetime: 15 | return datetime.strptime(value, "%Y-%m-%d") 16 | 17 | 18 | datetime_scalar = ScalarType( 19 | "Datetime", 20 | serializer=serialize_datetime, 21 | value_parser=parse_datetime_value 22 | ) 23 | -------------------------------------------------------------------------------- /app/data/database.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | from sqlalchemy import create_engine 3 | from sqlalchemy.orm import sessionmaker, registry 4 | 5 | from app.core.config import settings 6 | 7 | uri = settings.DATABASE_URL 8 | 9 | engine = create_engine(uri, echo=settings.DEVELOPMENT) 10 | 11 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 12 | mapper_registry = registry() 13 | 14 | 15 | def get_db() -> Generator: 16 | try: 17 | db = SessionLocal() 18 | yield db 19 | finally: 20 | db.close() # type: ignore 21 | -------------------------------------------------------------------------------- /app/api/graph_ql/resolvers/__init__.py: -------------------------------------------------------------------------------- 1 | from .case_resolvers import case, docketentry 2 | from .record_on_appeal_resolvers import ( 3 | record_on_appeal, 4 | record_on_appeal_docket_entry 5 | ) 6 | from .court_resolvers import court 7 | from .user_resolvers import user 8 | from .query_resolvers import query 9 | from .mutation_resolvers import mutation 10 | from .dev_helpers import dev_mutation 11 | 12 | resolvers = [ 13 | query, 14 | mutation, 15 | dev_mutation, 16 | case, 17 | court, 18 | user, 19 | docketentry, 20 | record_on_appeal, 21 | record_on_appeal_docket_entry 22 | ] 23 | -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | """${message} 4 | 5 | Revision ID: ${up_revision} 6 | Revises: ${down_revision | comma,n} 7 | Create Date: ${create_date} 8 | 9 | """ 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = ${repr(up_revision)} 16 | down_revision = ${repr(down_revision)} 17 | branch_labels = ${repr(branch_labels)} 18 | depends_on = ${repr(depends_on)} 19 | 20 | 21 | def upgrade(): 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade(): 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /app/entities/user.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from dataclasses import dataclass, field 3 | 4 | from .role import Role 5 | 6 | 7 | @dataclass 8 | class PublicUser(): 9 | id: int = 0 10 | full_name: str = 'public user' 11 | username: str = 'public_user' 12 | roles: List = field(default_factory=list) 13 | court_id = None 14 | 15 | 16 | @dataclass 17 | class User(): 18 | email: str 19 | full_name: str 20 | username: str 21 | hashed_password: str 22 | id: Optional[int] = None 23 | is_active: bool = True 24 | roles: List[Role] = field(default_factory=list) 25 | court_id: Optional[str] = None 26 | -------------------------------------------------------------------------------- /app/data/case/case_repo.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Any 2 | from sqlalchemy.orm import Session # , contains_eager 3 | 4 | from app.entities import Case 5 | from .case import run_mappers 6 | 7 | run_mappers() 8 | 9 | 10 | class CrudCase: 11 | ''' 12 | Create, read, update, and delete cases 13 | ''' 14 | def get(self, db: Session, id: Any) -> Optional[Case]: 15 | return db.query(Case).filter(Case.id == id).one_or_none() 16 | 17 | def add(self, db: Session, case): 18 | db.add(case) 19 | 20 | def create(self, db: Session, case: Case) -> Case: 21 | db.add(case) 22 | return case 23 | 24 | 25 | case = CrudCase() 26 | -------------------------------------------------------------------------------- /app/core/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class CourtType(str, Enum): 5 | district = "district" 6 | appellate = "appellate" 7 | bankruptcy = "bankruptcy" 8 | 9 | 10 | class CaseType(str, Enum): 11 | civil = "Civil" 12 | miscellaneous = "Miscellaneous" 13 | criminal = "Criminal" 14 | magistrate_judge = "Magistrate Judge" 15 | petty_offense = "Petty Offense" 16 | special = "Special" 17 | multidistrict_litigation = "Multidistrict Litigation" 18 | grand_jury = "Grand Jury" 19 | 20 | 21 | class CaseStatus(str, Enum): 22 | new = "new" 23 | on_appeal = "on_appeal" 24 | submitted_for_appeal = "submitted_for_appeal" 25 | -------------------------------------------------------------------------------- /app/api/api_v1/endpoints/cases.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from sqlalchemy.orm import Session 4 | from fastapi import APIRouter, Depends, HTTPException, status 5 | from app.api import dependency 6 | from app.data import case 7 | from app.data.database import get_db 8 | 9 | router = APIRouter() 10 | clerk = dependency.AllowRoles(['clerk']) 11 | 12 | 13 | @router.get("/{case_id}") 14 | def read_items( 15 | case_id: int, 16 | db: Session = Depends(get_db) 17 | ) -> Any: 18 | ''' 19 | Returns details about case associated with {case_id} 20 | ''' 21 | the_case = case.get(db, case_id) 22 | if the_case is None: 23 | raise HTTPException(status.HTTP_404_NOT_FOUND) 24 | return the_case 25 | -------------------------------------------------------------------------------- /app/data/role/role_repo.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Any 2 | 3 | from sqlalchemy.orm import Session 4 | from app.entities import Role 5 | from .role import run_mappers 6 | 7 | run_mappers() 8 | 9 | 10 | class CrudRole: 11 | ''' 12 | Create, read, update, and delete roles 13 | ''' 14 | def get(self, db: Session, id: Any) -> Optional[Role]: 15 | return db.query(Role).filter(Role.id == id).first() 16 | 17 | def get_by_name(self, db: Session, rolename: Any) -> Optional[Role]: 18 | return db.query(Role).filter(Role.rolename == rolename).first() 19 | 20 | def create(self, db: Session, role: Role) -> Role: 21 | db.add(role) 22 | return role 23 | 24 | 25 | role = CrudRole() 26 | -------------------------------------------------------------------------------- /app/docs/api_metadata.py: -------------------------------------------------------------------------------- 1 | ''' 2 | tags_metadata is used when serving the swagger API documentation. 3 | Each dictionary corresponds to a tag used in the API. The descriptions 4 | will apear in the docs. 5 | ''' 6 | tags_metadata = [ 7 | { 8 | "name": "login", 9 | "description": "Operations with users. The **login** logic is also here.", 10 | }, 11 | { 12 | "name": "cases", 13 | "description": "Manage cases and their metadata", 14 | "externalDocs": { 15 | "description": "Items external docs", 16 | "url": "https://github.com/ao-api", 17 | }, 18 | }, 19 | { 20 | "name": "GraphQL", 21 | "description": "- **GET:** the GraphQL sandbox \n- **POST:** handle graphQL queries." 22 | } 23 | ] 24 | -------------------------------------------------------------------------------- /app/api/graph_ql/routes_public.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | from starlette.requests import Request 3 | from sqlalchemy.orm import Session 4 | from ariadne.asgi import GraphQL 5 | from app.data.database import get_db 6 | from app.entities import PublicUser 7 | from . import schema 8 | 9 | graphql_app = GraphQL(schema, debug=False) 10 | graphQL_router_public = APIRouter() 11 | 12 | 13 | @graphQL_router_public.get("/") 14 | async def graphiql(request: Request): 15 | return await graphql_app.render_playground(request=request) 16 | 17 | 18 | @graphQL_router_public.post("/") 19 | async def graphql_post(request: Request, db: Session = Depends(get_db)): 20 | request.state.db = db 21 | request.state.user = PublicUser() 22 | return await graphql_app.graphql_http_server(request=request) 23 | -------------------------------------------------------------------------------- /app/api/graph_ql/__init__.py: -------------------------------------------------------------------------------- 1 | '''This package is responsible for defining GraphQL types, schema, and also provides 2 | fastAPI routes for including with a fastAPI app 3 | 4 | schema/ .graphql files defining the schema for the app. If there is more than file 5 | they will be merged into a single schema. 6 | [see also: https://ariadne.readthedocs.io/en/0.3.0/modularization.html] 7 | 8 | routes.py creates GraphQL app and adds routes for: 9 | GET route for the graphQL playground 10 | POST route for queries 11 | These get plugged into the main fastAPI app 12 | 13 | schema.py setup for Ariadne service 14 | 15 | resolvers/ resolver functions for the graphQL types. In general, business logic 16 | should NOT go here. 17 | 18 | ''' 19 | from .schema import schema 20 | -------------------------------------------------------------------------------- /alembic/versions/400c04778408_added_status_field_to_case.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | """Added status field to case 4 | 5 | Revision ID: 400c04778408 6 | Revises: a966106daa49 7 | Create Date: 2021-07-27 14:55:37.803628 8 | 9 | """ 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = '400c04778408' 16 | down_revision = 'a966106daa49' 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.add_column('cases', sa.Column('status', sa.String(), nullable=True)) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column('cases', 'status') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /alembic/versions/a966106daa49_added_court_field_to_case.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | """Added court field to case 4 | 5 | Revision ID: a966106daa49 6 | Revises: 7426da0d030a 7 | Create Date: 2021-07-26 16:36:14.756491 8 | 9 | """ 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = 'a966106daa49' 16 | down_revision = '7426da0d030a' 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.add_column('cases', sa.Column('court', sa.String(), nullable=True)) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column('cases', 'court') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /alembic/versions/878d3f26cbfd_add_court_id_to_user_table.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | """add court_id to user table 4 | 5 | Revision ID: 878d3f26cbfd 6 | Revises: 2c9cf71649e2 7 | Create Date: 2021-08-17 13:58:31.753872 8 | 9 | """ 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = '878d3f26cbfd' 16 | down_revision = '2c9cf71649e2' 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.add_column('users', sa.Column('court_id', sa.String(), nullable=True)) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column('users', 'court_id') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /app/tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from datetime import datetime 3 | from app.core.enums import CaseStatus 4 | from app.entities import DistrictCase, RecordOnAppeal 5 | 6 | 7 | @pytest.fixture() 8 | def simple_case(): 9 | case = DistrictCase( 10 | title="Godzilla v. Mothra", 11 | date_filed=datetime.now(), 12 | status=CaseStatus.new, 13 | sealed=True, 14 | court="tnmd", 15 | docket_entries=[] 16 | ) 17 | case.id = 123 18 | return case 19 | 20 | 21 | @pytest.fixture 22 | def simple_roa(): 23 | roa = RecordOnAppeal( 24 | court='nysd', 25 | title='Predator v. Alien', 26 | original_case_id=123, 27 | date_filed=datetime.now(), 28 | docket_entries=[], 29 | receiving_court=None, 30 | status=CaseStatus.new 31 | ) 32 | roa.id = 456 33 | return roa 34 | -------------------------------------------------------------------------------- /alembic/versions/7426da0d030a_add_roles.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | """ 3 | 4 | add_roles 5 | 6 | Revision ID: 7426da0d030a 7 | Revises: 179a279f5647 8 | Create Date: 2021-07-20 13:51:53.825585 9 | 10 | """ 11 | from alembic import op 12 | from sqlalchemy import String 13 | from sqlalchemy.sql import table, column 14 | 15 | 16 | # revision identifiers, used by Alembic. 17 | revision = '7426da0d030a' 18 | down_revision = '179a279f5647' 19 | branch_labels = None 20 | depends_on = None 21 | 22 | 23 | def upgrade(): 24 | roles_table = table( 25 | 'roles', 26 | column('rolename', String), 27 | ) 28 | 29 | op.bulk_insert( 30 | roles_table, 31 | [ 32 | {'rolename': 'admin'}, 33 | {'rolename': 'clerk'}, 34 | {'rolename': 'judge'}, 35 | ] 36 | ) 37 | 38 | 39 | def downgrade(): 40 | op.execute("delete from roles") 41 | -------------------------------------------------------------------------------- /app/api/graph_ql/routes_private.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | from starlette.requests import Request 3 | from sqlalchemy.orm import Session 4 | from ariadne.asgi import GraphQL 5 | from app.data.database import get_db 6 | from app.entities import User 7 | from app.api.dependency import get_current_user 8 | from . import schema 9 | 10 | graphql_app = GraphQL(schema, debug=False) 11 | graphQL_router_private = APIRouter() 12 | 13 | 14 | @graphQL_router_private.get("/") 15 | async def graphiql(request: Request): 16 | return await graphql_app.render_playground(request=request) 17 | 18 | 19 | @graphQL_router_private.post("/") 20 | async def graphql_post_private(request: Request, db: Session = Depends(get_db), user: User = Depends(get_current_user)): 21 | request.state.db = db 22 | request.state.user = user 23 | return await graphql_app.graphql_http_server(request=request) 24 | -------------------------------------------------------------------------------- /alembic/versions/5c02d93ce254_add_court_to_docket_entry_table_in_case_.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | """add court to docket entry table in case needed for row-based-access 4 | 5 | Revision ID: 5c02d93ce254 6 | Revises: 400c04778408 7 | Create Date: 2021-08-10 08:46:44.910568 8 | 9 | """ 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = '5c02d93ce254' 16 | down_revision = '400c04778408' 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.add_column('docket_entries', sa.Column('court', sa.String(), nullable=True)) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column('docket_entries', 'court') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /app/entities/court.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, TypeVar, Type, List 2 | from dataclasses import dataclass 3 | from app.core.enums import CourtType 4 | from app.core.courts import courts 5 | 6 | T = TypeVar('T', bound='Court') 7 | 8 | 9 | @dataclass(frozen=True) 10 | class Court(): 11 | ''' 12 | Info on Federal Courts 13 | ''' 14 | id: str 15 | type: CourtType 16 | short_name: str 17 | full_name: str 18 | parent: Optional[str] = None 19 | 20 | @classmethod 21 | def from_id(cls: Type[T], court_id: str) -> T: 22 | court_data = courts[court_id] 23 | return cls(**court_data, id=court_id) 24 | 25 | def parent_court(self: T) -> Optional[T]: 26 | if self.parent: 27 | return courts.get(self.parent) 28 | 29 | def lower_courts(self: T) -> List[T]: 30 | print(courts) 31 | return [self.__class__.from_id(id) for id, c in courts.items() if c['parent'] == self.id] 32 | -------------------------------------------------------------------------------- /app/api/graph_ql/resolvers/record_on_appeal_resolvers.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from ariadne import ObjectType 4 | from app.entities import RecordOnAppeal, RecordOnAppealDocketEntry, Court 5 | 6 | 7 | record_on_appeal = ObjectType("RecordOnAppeal") 8 | record_on_appeal_docket_entry = ObjectType("RecordOnAppealDocketEntry") 9 | record_on_appeal_docket_entry.set_alias("sequenceNumber", "sequence_no") 10 | 11 | 12 | @record_on_appeal.field("docketEntries") 13 | def resolve_docket_entries(obj: RecordOnAppeal, *_) -> List[RecordOnAppealDocketEntry]: 14 | return obj.docket_entries 15 | 16 | 17 | @record_on_appeal.field("court") 18 | def resolve_court(obj: RecordOnAppeal, *_) -> Optional[Court]: 19 | if obj.court: 20 | return Court.from_id(obj.court) 21 | 22 | 23 | @record_on_appeal.field("receivingCourt") 24 | def resolve_receivingCourt(obj: RecordOnAppeal, *_) -> Optional[Court]: 25 | if obj.receiving_court: 26 | return Court.from_id(obj.receiving_court) 27 | -------------------------------------------------------------------------------- /app/api/graph_ql/resolvers/case_resolvers.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Union 2 | 3 | from ariadne import ObjectType, InterfaceType 4 | from app.entities import DocketEntry, Case, DistrictCase, AppellateCase, Court 5 | 6 | case = InterfaceType("Case") 7 | docketentry = ObjectType("DocketEntry") 8 | docketentry.set_alias("sequenceNumber", "sequence_no") 9 | 10 | 11 | @case.type_resolver 12 | def case_result_type(obj, *_): 13 | if isinstance(obj, DistrictCase): 14 | return "DistrictCase" 15 | if isinstance(obj, AppellateCase): 16 | return "AppellateCase" 17 | 18 | 19 | @case.field("docketEntries") 20 | def resolve_docket_entries(obj: Union[DistrictCase, AppellateCase], *_) -> List[DocketEntry]: 21 | # at the moment the data query grabs the whole docket, so this is convenient 22 | # this will probably change 23 | return obj.docket_entries 24 | 25 | 26 | @case.field("court") 27 | def resolve_court(obj: Case, *_) -> Optional[Court]: 28 | return Court.from_id(obj.court) 29 | -------------------------------------------------------------------------------- /app/core/security.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Any, Union 3 | from jose import jwt 4 | from passlib.context import CryptContext 5 | 6 | from app.core.config import settings 7 | 8 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 9 | 10 | ALGORITHM = "HS256" 11 | 12 | 13 | def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str: 14 | if expires_delta: 15 | expire = datetime.utcnow() + expires_delta 16 | else: 17 | expire = datetime.utcnow() + timedelta( 18 | minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES 19 | ) 20 | to_encode = {"exp": expire, "sub": str(subject)} 21 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) 22 | return encoded_jwt 23 | 24 | 25 | def verify_password(plain_password: str, hashed_password: str) -> bool: 26 | return pwd_context.verify(plain_password, hashed_password) 27 | 28 | 29 | def get_password_hash(password: str) -> str: 30 | return pwd_context.hash(password) 31 | -------------------------------------------------------------------------------- /app/entities/record_on_appeal.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import List, Optional 3 | from dataclasses import dataclass, field 4 | 5 | from .case_entity import CaseEntity 6 | from .docket_entry import DocketEntry 7 | from .court import Court 8 | 9 | 10 | @dataclass 11 | class RecordOnAppealDocketEntry(DocketEntry): 12 | recieving_court: Optional[str] = None 13 | include_with_appeal: bool = True 14 | 15 | @classmethod 16 | def from_docket_entry(cls, docket_entry): 17 | fields = ['case_id', 'court', 'sequence_no', 'text', 'date_filed', 'entry_type', 'sealed'] 18 | return cls(**dict([(field, getattr(docket_entry, field)) for field in fields])) 19 | 20 | 21 | @dataclass 22 | class RecordOnAppeal(CaseEntity): 23 | title: str 24 | original_case_id: int 25 | receiving_court: Optional[str] 26 | date_filed: date 27 | status: Optional[str] 28 | docket_entries: List[RecordOnAppealDocketEntry] = field(default_factory=list) 29 | sealed: bool = False 30 | 31 | def send_to_court(self, court: Court): 32 | self.receiving_court = court.id 33 | -------------------------------------------------------------------------------- /.github/workflows/run-api-tests-action.yml: -------------------------------------------------------------------------------- 1 | name: run-api-tests-action 2 | on: 3 | push: 4 | branches: [main, api-dev] 5 | pull_request: 6 | branches: [main, api-dev] 7 | jobs: 8 | run-tests: 9 | runs-on: ubuntu-latest 10 | services: 11 | postgres: 12 | # Docker Hub image 13 | image: postgres 14 | env: 15 | POSTGRES_PASSWORD: postgres 16 | options: >- 17 | --health-cmd pg_isready 18 | --health-interval 10s 19 | --health-timeout 5s 20 | --health-retries 5 21 | ports: 22 | - 5432:5432 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Set up Python 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: 3.8 30 | - name: Install Dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install -r requirements.txt 34 | - name: Run Test Suite 35 | run: pytest app/tests 36 | env: 37 | DATABASE_URL_TEST: "postgresql://postgres:postgres@localhost:5432/postgres" -------------------------------------------------------------------------------- /alembic/README.md: -------------------------------------------------------------------------------- 1 | # Database schema migrations use Alembic 2 | 3 | ### Basic Use 4 | 5 | Database migrations allow the state of the database schema to be tracked in version control and make it easier for developers to work together. Each change in the database is represented by a file in `./versions` which provide code to move the database to a new state and also reverse the change. 6 | 7 | To start a new migration, call it with `revision` and provide a name: 8 | 9 | ``` 10 | alembic revision -m "add_roles" 11 | ``` 12 | 13 | This creates a new file in `./versions` and links it to the previous versions. 14 | 15 | **Inspect this file!!** Make sure it does what you want it to. The `downgrade()` function should return the database back to the state before this migration ran. If you are satisfied, bring the database up-to-date by running the migration: 16 | 17 | ``` 18 | alembic upgrade head 19 | ``` 20 | 21 | To step backward one steop through the migrations: 22 | 23 | ``` 24 | alembic downgrade -1 25 | ``` 26 | 27 | For more datails, see the [Alembic Documentaion](https://alembic.sqlalchemy.org/en/latest/index.html) 28 | -------------------------------------------------------------------------------- /app/data/user/user_repo.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Any 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from app.entities import User 6 | from app.core.security import verify_password 7 | from .user import run_mappers 8 | 9 | run_mappers() 10 | 11 | 12 | class CrudUser: 13 | ''' 14 | Create, read, update, and delete users 15 | ''' 16 | def get(self, db: Session, id: Any) -> Optional[User]: 17 | return db.query(User).filter(User.id == id).one_or_none() 18 | 19 | def add(self, db: Session, user: User): 20 | db.add(user) 21 | 22 | def get_by_email(self, db: Session, email: str) -> Optional[User]: 23 | return db.query(User).filter(User.email == email).one_or_none() 24 | 25 | def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]: 26 | user = self.get_by_email(db, email=email) 27 | if not user: 28 | return None 29 | if not verify_password(password, user.hashed_password): 30 | return None 31 | return user 32 | 33 | def is_active(self, user: User) -> bool: 34 | return user.is_active 35 | 36 | 37 | user = CrudUser() 38 | -------------------------------------------------------------------------------- /app/api/graph_ql/resolvers/query_resolvers.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | from ariadne import QueryType 3 | from ariadne.types import GraphQLResolveInfo 4 | 5 | from app.data import case, record_on_appeal 6 | from app.entities import Case, Court, RecordOnAppeal, User 7 | 8 | 9 | query = QueryType() 10 | 11 | 12 | @query.field("case") 13 | def resolve_case(obj: Any, info: GraphQLResolveInfo, id) -> Optional[Case]: 14 | session = info.context['request'].state.db 15 | case_data = case.get(session, id) 16 | if case_data: 17 | return case_data 18 | 19 | 20 | @query.field("recordOnAppeal") 21 | def resolve_roa(obj: Any, info: GraphQLResolveInfo, id) -> Optional[RecordOnAppeal]: 22 | session = info.context['request'].state.db 23 | roa_data = record_on_appeal.get(session, id) 24 | if roa_data: 25 | return roa_data 26 | 27 | 28 | @query.field("court") 29 | def resolve_court(obj: Any, info: GraphQLResolveInfo, id) -> Optional[Court]: 30 | return Court.from_id(id) 31 | 32 | 33 | @query.field("currentuser") 34 | def resolve_current_user(obj: Any, info: GraphQLResolveInfo) -> Optional[User]: 35 | user = info.context['request'].state.user 36 | if user: 37 | return user 38 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fastapi import FastAPI 4 | import uvicorn 5 | from starlette.middleware.cors import CORSMiddleware 6 | 7 | from app.core.config import settings 8 | from app.api.api_v1.api import api_router 9 | from app.docs.api_metadata import tags_metadata 10 | from app.api.graph_ql.routes_public import graphQL_router_public 11 | from app.api.graph_ql.routes_private import graphQL_router_private 12 | 13 | 14 | app = FastAPI( 15 | title=settings.PROJECT_NAME, 16 | openapi_url=f"{settings.API_V1_STR}/openapi.json", 17 | openapi_tags=tags_metadata 18 | ) 19 | 20 | if settings.BACKEND_CORS_ORIGINS: 21 | app.add_middleware( 22 | CORSMiddleware, 23 | allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], 24 | allow_credentials=True, 25 | allow_methods=["*"], 26 | allow_headers=["*"], 27 | ) 28 | 29 | 30 | app.include_router(api_router, prefix=settings.API_V1_STR) 31 | app.include_router(graphQL_router_public, prefix=settings.GRAPHQL_ENDPOINT_PUBLIC, tags=["GraphQL"]) 32 | app.include_router(graphQL_router_private, prefix=settings.GRAPHQL_ENDPOINT_PRIVATE, tags=["GraphQL"]) 33 | 34 | 35 | if __name__ == "__main__": 36 | port = int(os.getenv("PORT", 8080)) 37 | uvicorn.run(app, host="0.0.0.0", port=port) # type: ignore 38 | -------------------------------------------------------------------------------- /app/tests/unit/test_case_resolvers.py: -------------------------------------------------------------------------------- 1 | from app.core.enums import CaseStatus 2 | from datetime import datetime 3 | from app.api.graph_ql.resolvers.case_resolvers import ( 4 | case_result_type, 5 | resolve_docket_entries 6 | ) 7 | from app.entities import DistrictCase, AppellateCase, DocketEntry 8 | 9 | 10 | case_params = { 11 | 'title': "Foreman vs Ali", 12 | 'date_filed': datetime.now(), 13 | 'court': 'tnmd', 14 | 'status': CaseStatus.new 15 | } 16 | 17 | docket_params = { 18 | 'case_id': 1, 19 | 'text': "round 9", 20 | 'court': "nysd", 21 | 'sequence_no': 5, 22 | 'date_filed': datetime.now(), 23 | 'entry_type': 'ord' 24 | } 25 | 26 | 27 | def test_case_result_type(): 28 | '''It should resolve to the correct string based on object''' 29 | 30 | d = DistrictCase(**case_params) 31 | assert case_result_type(d) == 'DistrictCase' 32 | 33 | a = AppellateCase(**case_params, original_case_id=2) 34 | assert case_result_type(a) == 'AppellateCase' 35 | 36 | 37 | def test_resolve_docket_entries(): 38 | '''It should resolve to the docket entry list on the case''' 39 | 40 | docket = [DocketEntry(**docket_params)] 41 | d = DistrictCase(**case_params, docket_entries=docket) 42 | resolved = resolve_docket_entries(d) 43 | assert resolved == docket 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Welcome! 2 | 3 | We're so glad you're thinking about contributing to an 18F open source project! 4 | If you're unsure about anything, just ask -- or submit the issue or pull request 5 | anyway. The worst that can happen is you'll be politely asked to change 6 | something. We love all friendly contributions. 7 | 8 | We want to ensure a welcoming environment for all of our projects. Our staff 9 | follow the [18F Code of 10 | Conduct](https://github.com/18F/code-of-conduct/blob/master/code-of-conduct.md) 11 | and all contributors should do the same. 12 | 13 | We encourage you to read this project's CONTRIBUTING policy (you are here), its 14 | [LICENSE](LICENSE.md), and its [README](README.md). 15 | 16 | If you have any questions or want to read more, check out the [18F Open Source 17 | Policy GitHub repository]( https://github.com/18f/open-source-policy), or just 18 | [shoot us an email](mailto:18f@gsa.gov). 19 | 20 | ## Public domain 21 | 22 | This project is in the public domain within the United States, and copyright and 23 | related rights in the work worldwide are waived through the [CC0 1.0 Universal 24 | public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/). 25 | 26 | All contributions to this project will be released under the CC0 27 | dedication. By submitting a pull request, you are agreeing to comply 28 | with this waiver of copyright interest. 29 | -------------------------------------------------------------------------------- /app/api/api_v1/endpoints/login.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Any 3 | 4 | from sqlalchemy.orm import Session 5 | from fastapi import APIRouter, HTTPException, Depends 6 | from fastapi.security import OAuth2PasswordRequestForm 7 | from app.entities import Token 8 | from app import data 9 | from app.core.config import settings 10 | from app.core import security 11 | from app.data.database import get_db 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.post("/login/access-token", response_model=Token, summary="Get an access token") 17 | def login_access_token( 18 | db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends() 19 | ) -> Any: 20 | ''' 21 | Exchange name and password credentials for a JWT. 22 | ''' 23 | user = data.user.authenticate( 24 | db, email=form_data.username, password=form_data.password 25 | ) 26 | 27 | if not user: 28 | raise HTTPException(status_code=400, detail="Incorrect email or password") 29 | elif not user.is_active: 30 | raise HTTPException(status_code=400, detail="Inactive user") 31 | access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) 32 | return { 33 | "access_token": security.create_access_token( 34 | user.id, expires_delta=access_token_expires 35 | ), 36 | "token_type": "bearer", 37 | } 38 | -------------------------------------------------------------------------------- /app/data/user/user.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from sqlalchemy import Boolean, Column, Integer, String, Table, DateTime, ForeignKey 3 | from sqlalchemy.orm import relationship 4 | 5 | from ..database import mapper_registry 6 | from app.entities import User, Role 7 | 8 | 9 | association_table = Table( 10 | 'user_roles', 11 | mapper_registry.metadata, 12 | Column('user_id', Integer, ForeignKey('users.id', ondelete="CASCADE")), 13 | Column('role_id', Integer, ForeignKey('roles.id', ondelete="CASCADE")) 14 | ) 15 | 16 | 17 | user_table = Table( 18 | 'users', 19 | mapper_registry.metadata, 20 | Column('id', Integer, primary_key=True, index=True), 21 | Column('username', String), 22 | Column('court_id', String), 23 | Column('email', String, unique=True, index=True), 24 | Column('full_name', String), 25 | Column('hashed_password', String), 26 | Column('is_active', Boolean, default=True), 27 | Column('created_at', DateTime, default=datetime.datetime.utcnow), 28 | Column( 29 | 'updated_on', 30 | DateTime, 31 | default=datetime.datetime.utcnow, 32 | onupdate=datetime.datetime.utcnow 33 | ) 34 | ) 35 | 36 | 37 | def run_mappers(): 38 | mapper_registry.map_imperatively( 39 | User, 40 | user_table, 41 | properties={ 42 | 'roles': relationship(Role, secondary=association_table) 43 | } 44 | ) 45 | -------------------------------------------------------------------------------- /app/data/record_on_appeal/record_on_appeal_repo.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Any 2 | from sqlalchemy.orm import Session # , contains_eager 3 | 4 | from app.entities import RecordOnAppeal, RecordOnAppealDocketEntry 5 | from .record_on_appeal import run_mappers 6 | 7 | 8 | run_mappers() 9 | 10 | 11 | class CrudRoa: 12 | ''' 13 | Create, read, update, and delete cases 14 | ''' 15 | def get(self, db: Session, id: Any) -> Optional[RecordOnAppeal]: 16 | return db.query(RecordOnAppeal).filter(RecordOnAppeal.id == id).one_or_none() 17 | 18 | def add(self, db: Session, roa): 19 | db.add(roa) 20 | 21 | def create(self, db: Session, roa: RecordOnAppeal) -> RecordOnAppeal: 22 | db.add(roa) 23 | return roa 24 | 25 | 26 | record_on_appeal = CrudRoa() 27 | 28 | 29 | class CrudRoaDocketEntry: 30 | ''' 31 | Create, read, update, and delete cases 32 | ''' 33 | def get(self, db: Session, id: Any) -> Optional[RecordOnAppealDocketEntry]: 34 | return db.query(RecordOnAppealDocketEntry).filter(RecordOnAppealDocketEntry.id == id).one_or_none() 35 | 36 | def add(self, db: Session, roa_docket): 37 | db.add(roa_docket) 38 | 39 | def create(self, db: Session, roa_docket: RecordOnAppealDocketEntry) -> RecordOnAppealDocketEntry: 40 | db.add(roa_docket) 41 | return roa_docket 42 | 43 | 44 | record_on_appeal_docket_entry = CrudRoaDocketEntry() 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | As a work of the [United States government](https://www.usa.gov/), this project is in the public domain within the United States of America. 4 | 5 | Additionally, we waive copyright and related rights in the work worldwide through the CC0 1.0 Universal public domain dedication. 6 | 7 | ## CC0 1.0 Universal Summary 8 | 9 | This is a human-readable summary of the [Legal Code (read the full text)](https://creativecommons.org/publicdomain/zero/1.0/legalcode). 10 | 11 | ### No Copyright 12 | 13 | The person who associated a work with this deed has dedicated the work to the public domain by waiving all of their rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law. 14 | 15 | You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission. 16 | 17 | ### Other Information 18 | 19 | In no way are the patent or trademark rights of any person affected by CC0, nor are the rights that other persons may have in the work or in how the work is used, such as publicity or privacy rights. 20 | 21 | Unless expressly stated otherwise, the person who associated a work with this deed makes no warranties about the work, and disclaims liability for all uses of the work, to the fullest extent permitted by applicable law. When using or citing the work, you should not imply endorsement by the author or the affirmer. 22 | -------------------------------------------------------------------------------- /.github/workflows/api-test-deploy.yml: -------------------------------------------------------------------------------- 1 | name: run-api-tests-action 2 | on: 3 | push: 4 | branches: [main] 5 | jobs: 6 | pre-deploy-tests: 7 | runs-on: ubuntu-latest 8 | services: 9 | postgres: 10 | # Docker Hub image 11 | image: postgres 12 | env: 13 | POSTGRES_PASSWORD: postgres 14 | options: >- 15 | --health-cmd pg_isready 16 | --health-interval 10s 17 | --health-timeout 5s 18 | --health-retries 5 19 | ports: 20 | - 5432:5432 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Python 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: 3.8 28 | - name: Install Dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r requirements.txt 32 | - name: Run Test Suite 33 | run: pytest app/tests 34 | env: 35 | DATABASE_URL_TEST: "postgresql://postgres:postgres@localhost:5432/postgres" 36 | deploy: 37 | runs-on: ubuntu-latest 38 | needs: pre-deploy-tests 39 | steps: 40 | - uses: actions/checkout@v2 41 | - name: Deploy to cloud.gov 42 | uses: cloud-gov/cg-cli-tools@main 43 | with: 44 | cf_api: https://api.fr.cloud.gov 45 | cf_username: ${{ secrets.CG_USERNAME }} 46 | cf_password: ${{ secrets.CG_PASSWORD }} 47 | cf_org: sandbox-gsa 48 | cf_space: mark.meyer 49 | -------------------------------------------------------------------------------- /app/tests/unit/test_graphql_queries.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, Mock, patch, ANY 2 | 3 | from app.api.graph_ql.resolvers.query_resolvers import resolve_case, resolve_roa 4 | 5 | 6 | @patch('app.api.graph_ql.resolvers.query_resolvers.case') 7 | def test_case_resolver(case_patch, simple_case): 8 | '''It should return the case provided by the database''' 9 | info = MagicMock() 10 | case_patch.get = Mock(return_value=simple_case) 11 | assert resolve_case({}, info, id=1) == simple_case 12 | case_patch.get.assert_called_with(ANY, 1) 13 | 14 | 15 | @patch('app.api.graph_ql.resolvers.query_resolvers.case') 16 | def test_case_query_not_found(case_patch): 17 | '''It should return None if the database returns None''' 18 | info = MagicMock() 19 | case_patch.get = Mock(return_value=None) 20 | assert resolve_case({}, info, id=1) is None 21 | 22 | 23 | @patch('app.api.graph_ql.resolvers.query_resolvers.record_on_appeal') 24 | def test_roa_resolver(roa_patch, simple_roa): 25 | '''It should return roa if the database returns None''' 26 | info = MagicMock() 27 | roa_patch.get = Mock(return_value=simple_roa) 28 | assert resolve_roa({}, info, id=10) == simple_roa 29 | 30 | 31 | @patch('app.api.graph_ql.resolvers.query_resolvers.record_on_appeal') 32 | def test_roa_resolver_not_found(roa_patch, simple_roa): 33 | '''It should return roa if the database returns None''' 34 | info = MagicMock() 35 | roa_patch.get = Mock(return_value=None) 36 | assert resolve_roa({}, info, id=10) is None 37 | -------------------------------------------------------------------------------- /app/data/dev_helpers.py: -------------------------------------------------------------------------------- 1 | ''' 2 | These DB functions are only here to help reset data quickly during development. 3 | There is [probably] no reason to have them after initial dev work 4 | ''' 5 | import json 6 | from app.entities import DistrictCase, DocketEntry, Case, RecordOnAppeal, RecordOnAppealDocketEntry 7 | from sqlalchemy.orm import Session 8 | 9 | 10 | CASE_DATA_PATH = "./seed_data/case.json" 11 | 12 | 13 | class CaseDevUtil: 14 | def delete_all(self, db: Session) -> bool: 15 | '''Deletes all cases from Database -- only for development''' 16 | db.query(RecordOnAppealDocketEntry).delete() 17 | db.query(RecordOnAppeal).delete() 18 | db.query(DocketEntry).delete() 19 | db.query(Case).delete() 20 | db.commit() 21 | return True 22 | 23 | def add_seed_cases(self, db: Session) -> bool: 24 | db.execute("ALTER SEQUENCE cases_id_seq RESTART WITH 1") 25 | db.execute("ALTER SEQUENCE docket_entries_id_seq RESTART WITH 1") 26 | db.execute("ALTER SEQUENCE records_on_appeal_id_seq RESTART WITH 1") 27 | db.execute("ALTER SEQUENCE roa_docket_entry_id_seq RESTART WITH 1") 28 | 29 | with open(CASE_DATA_PATH, 'r') as case_file: 30 | cases = json.load(case_file) 31 | for case in cases: 32 | case['docket_entries'] = [DocketEntry(**d, court=case['court']) for d in case['docket_entries']] 33 | c = DistrictCase(**case) 34 | db.add(c) 35 | db.commit() 36 | return True 37 | 38 | 39 | case_dev_util = CaseDevUtil() 40 | -------------------------------------------------------------------------------- /app/core/config.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | from pydantic import BaseSettings, validator 3 | 4 | 5 | class Settings(BaseSettings): 6 | ''' 7 | App-wide configurations. Where values are not provided this will attempt 8 | to use evnironmental variables or look in an `.env` file in the main directory. 9 | ''' 10 | API_V1_STR: str = "/api/v1" 11 | GRAPHQL_ENDPOINT_PUBLIC: str = "/graphql" 12 | GRAPHQL_ENDPOINT_PRIVATE: str = "/graphql_private" 13 | 14 | PROJECT_NAME: str = "AO Backend" 15 | SECRET_KEY: str = "123" 16 | BACKEND_CORS_ORIGINS: List[str] = ['*'] 17 | ACCESS_TOKEN_EXPIRE_MINUTES = 60 18 | DATABASE_URL: str = "sqlite://" 19 | DATABASE_URL_TEST: str = "sqlite://" 20 | INITIAL_ADMIN_USER: str = "admin@example.com" 21 | INITIAL_ADMIN_PASSWORD: str = "secret" 22 | DEVELOPMENT: bool = False 23 | 24 | @validator("DATABASE_URL", pre=True) 25 | def fix_postgres_url(cls, v: str) -> str: 26 | '''Fixes DATABASE_URL from Cloud.gov so sqlalchemy will accept it''' 27 | if v.startswith("postgres://"): 28 | return v.replace("postgres://", "postgresql://", 1) 29 | return v 30 | 31 | @validator("BACKEND_CORS_ORIGINS", pre=True) 32 | def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]: 33 | if isinstance(v, str) and not v.startswith("["): 34 | return [i.strip() for i in v.split(",")] 35 | elif isinstance(v, (list, str)): 36 | return v 37 | raise ValueError(v) 38 | 39 | class Config: 40 | case_sensitive = True 41 | env_file = ".env" 42 | 43 | 44 | settings = Settings() 45 | -------------------------------------------------------------------------------- /doc/adr/0001-cloud-dot-gov.md: -------------------------------------------------------------------------------- 1 | # 1. Hosting on cloud.gov 2 | 3 | Date: 2021-08-09 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | The purpose of this API is to support the E&I phase of 18F's work with the Administrative Office of the Courts. This work includes creating a small API as well as demonstrating DevOps practices. As such, this project should be continuously deployable in a simple, dependable way. There are many ways to do this including using bash scripts and/or Terraform or other infrastructure-as-code tools with a cloud provider's APIs. For this phase of work our primary infrastructure requirements are a relational database service and a virtual server that is capable of executing Python code and communicating with the server and serving incoming user requests. Additionally, we require a means to deploy to this services in a repeatable, automated way. 12 | 13 | ## Decision 14 | 15 | For this phase of work we will use Cloud.gov and the RDS database service (PostgreSQL) is provides. 16 | 17 | ## Consequences 18 | 19 | Cloud.gov provides all of the services this API currently needs. Additionally, Cloud.gov includes a simple and reliable method to deploy new instances with `cf push`, which works well in continuous integration/delivery tools like CircleCI or Github Actions. Because Cloud.gov already has a Provisional Authority to Operate (P-ATO), it can simplify compliance with federal requirements. 20 | 21 | While Cloud.gov runs on top of AWS services and provides many services, including Elasticsearch, Redis, and S3, it does not provide everything available on AWS. It is conceivable that future phases of this project may benefit from services such as Lambda functions, SQS queues, or SNS notifications, which are not currently available on Cloud.gov. 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/data/init_db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | from sqlalchemy import create_engine 3 | 4 | from app.data import user, role 5 | from app.core.config import settings 6 | from app.entities import User 7 | from .database import mapper_registry, SessionLocal 8 | from app.core.security import get_password_hash 9 | 10 | 11 | def init_db(db: Session) -> None: 12 | '''This is a stub for inserting initial data that may be needed for the application, 13 | such as initial users. At the moment this is just a couple roles and 14 | an admin user with these roles assigned. 15 | 16 | Structural changes to the database should happen in migrations, not here. 17 | In fact, if if turns out data like roles is needed for the application, we 18 | may opt to put this in migrations as well. 19 | ''' 20 | initial = user.get_by_email(db, email=settings.INITIAL_ADMIN_USER) 21 | if not initial: 22 | admin_role = role.get_by_name(db, rolename='admin') 23 | clerk_role = role.get_by_name(db, rolename='clerk') 24 | 25 | hashed_password = get_password_hash(settings.INITIAL_ADMIN_PASSWORD) 26 | roles = [r for r in (admin_role, clerk_role) if r] 27 | 28 | user_in = User( 29 | email=settings.INITIAL_ADMIN_USER, 30 | hashed_password=hashed_password, 31 | roles=roles, 32 | full_name="Initial Admin", 33 | username="admin" 34 | ) 35 | user.add(db, user_in) 36 | db.commit() 37 | 38 | 39 | def create_tables(): 40 | '''Set up tables for the tests''' 41 | engine = create_engine(settings.DATABASE_URL) 42 | mapper_registry.metadata.create_all(engine) 43 | 44 | 45 | if __name__ == "__main__": 46 | create_tables() 47 | db = SessionLocal() 48 | init_db(db) 49 | -------------------------------------------------------------------------------- /app/tests/unit/test_court_resolvers.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | import pytest 3 | from app.api.graph_ql.resolvers.court_resolvers import resolve_lower_courts 4 | from app.entities import Court 5 | 6 | 7 | fake_courts = { 8 | 'app': {'type': 'appellate', 9 | 'full_name': 'Some Appellate Court', 10 | 'parent': None, 11 | 'short_name': 'Cicuit 1'}, 12 | 'd1': {'type': 'district', 13 | 'full_name': 'a district court', 14 | 'parent': 'app', 15 | 'short_name': 'D. one'}, 16 | 'd2': {'type': 'district', 17 | 'full_name': 'another distirct court', 18 | 'parent': 'app', 19 | 'short_name': 'D. Two'}, 20 | 'd3': {'type': 'district', 21 | 'full_name': 'yet another distirct court', 22 | 'parent': 'app4', 23 | 'short_name': 'D. Three'}, 24 | 'app2': {'type': 'appellate', 25 | 'full_name': 'Some Other Appellate Court', 26 | 'parent': None, 27 | 'short_name': 'Cicuit 2'} 28 | } 29 | 30 | 31 | @patch('app.entities.court.courts', fake_courts) 32 | def test_resolve_lower_courts(): 33 | '''It should resolve to a list of lower courts under the given court''' 34 | c = Court(**fake_courts['app'], id='app') 35 | resolved = resolve_lower_courts(c) 36 | assert resolved == [ 37 | Court(**fake_courts['d1'], id='d1'), 38 | Court(**fake_courts['d2'], id='d2') 39 | ] 40 | 41 | 42 | @patch('app.entities.court.courts', fake_courts) 43 | @pytest.mark.parametrize('court_id', ['app2', 'd1']) 44 | def test_resolve_no_lower_courts(court_id): 45 | '''It should resolve to an empty list when there are no lower courts''' 46 | c = Court(**fake_courts[court_id], id=court_id) 47 | resolved = resolve_lower_courts(c) 48 | assert resolved == [] 49 | -------------------------------------------------------------------------------- /app/tests/unit/test_roa_entity.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from app.core.enums import CaseStatus 3 | from app.entities import RecordOnAppeal, Court 4 | 5 | 6 | def test_roa_from_district_case(simple_case) -> None: 7 | ''' 8 | It should create an record of appeal for this case, set the original_case_id. 9 | ''' 10 | court = Court.from_id('ca9') 11 | roa = simple_case.create_record_on_appeal(court) 12 | assert isinstance(roa, RecordOnAppeal) 13 | assert roa.original_case_id == simple_case.id 14 | assert roa.receiving_court == 'ca9' 15 | assert roa.court == simple_case.court 16 | 17 | 18 | def test_roa_from_district_case_no_appellate_court(simple_case) -> None: 19 | ''' 20 | It should not set the receiving court automatically. 21 | ''' 22 | roa = simple_case.create_record_on_appeal() 23 | assert roa.receiving_court == None 24 | assert roa.court == simple_case.court 25 | 26 | 27 | def test_district_case_status_roa(simple_case) -> None: 28 | ''' 29 | It should change status of original case to submitted_for_appeal. 30 | ''' 31 | _ = simple_case.create_record_on_appeal() 32 | assert simple_case.status == CaseStatus.submitted_for_appeal 33 | 34 | 35 | def test_validates_roa(simple_case) -> None: 36 | ''' 37 | It should raise an exception if an record of appeal is created when one exists. 38 | ''' 39 | _ = simple_case.create_record_on_appeal() 40 | assert simple_case.status == CaseStatus.submitted_for_appeal 41 | with pytest.raises(ValueError): 42 | _ = simple_case.create_record_on_appeal() 43 | 44 | 45 | def test_send_roa(simple_case) -> None: 46 | ''' 47 | If should set the receiving court on the record on appeal. 48 | ''' 49 | roa = simple_case.create_record_on_appeal() 50 | roa.send_to_court(Court.from_id('ca9')) 51 | assert roa.receiving_court == 'ca9' 52 | -------------------------------------------------------------------------------- /app/api/dependency.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from pydantic import ValidationError 4 | from fastapi import Depends, HTTPException, status 5 | from jose import jwt 6 | from fastapi.security import OAuth2PasswordBearer 7 | from sqlalchemy.orm import Session 8 | 9 | from app.core.config import settings 10 | from app.data import user 11 | from app.entities import TokenPayload, User 12 | from app.core import security 13 | from app.data.database import get_db 14 | 15 | reusable_oauth2 = OAuth2PasswordBearer( 16 | tokenUrl=f"{settings.API_V1_STR}/login/access-token" 17 | ) 18 | 19 | 20 | def get_current_user( 21 | db: Session = Depends(get_db), token: str = Depends(reusable_oauth2) 22 | ) -> User: 23 | try: 24 | payload = jwt.decode( 25 | token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] 26 | ) 27 | token_data = TokenPayload(**payload) 28 | except (jwt.JWTError, ValidationError): 29 | raise HTTPException( 30 | status_code=status.HTTP_403_FORBIDDEN, 31 | detail="Could not validate credentials", 32 | ) 33 | current_user = user.get(db, id=token_data.sub) 34 | if not current_user: 35 | raise HTTPException(status_code=404, detail="User not found") 36 | return current_user 37 | 38 | 39 | def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: 40 | if not user.is_active(current_user): 41 | raise HTTPException(status_code=400, detail="Inactive user") 42 | return current_user 43 | 44 | 45 | class AllowRoles(): 46 | def __init__(self, roles: Iterable[str]): 47 | self.autorized_roles = list(roles) 48 | 49 | def __call__(self, user: User = Depends(get_current_active_user)) -> User: 50 | if not any(role.rolename in self.autorized_roles for role in user.roles): 51 | raise HTTPException(status_code=403, detail="Operation not permitted") 52 | return user 53 | -------------------------------------------------------------------------------- /app/tests/integration/test_auth.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | from sqlalchemy.orm import Session 4 | from app.core.config import settings 5 | 6 | 7 | def test_get_access_token_valid_user(client: TestClient, db_session: Session, default_user) -> None: 8 | '''Correct credentials should return HTTP 200 and an access token''' 9 | login_data = { 10 | "username": settings.INITIAL_ADMIN_USER, 11 | "password": settings.INITIAL_ADMIN_PASSWORD, 12 | } 13 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) 14 | tokens = r.json() 15 | assert r.status_code == 200 16 | assert "access_token" in tokens 17 | assert tokens["access_token"] 18 | 19 | 20 | @pytest.mark.parametrize('login_data', [ 21 | {"username": settings.INITIAL_ADMIN_USER, "password": "Whoops"}, 22 | {"username": 'bad@example.com', "password": settings.INITIAL_ADMIN_PASSWORD}, 23 | {"username": 'bad@example.com', "password": 'whoops'}, 24 | ]) 25 | def test_get_access_token_invalid_user(login_data, client: TestClient, db_session: Session, default_user) -> None: 26 | '''Incorrect credentials should return HTTP 400 and no token''' 27 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) 28 | tokens = r.json() 29 | assert r.status_code == 400 30 | assert "access_token" not in tokens 31 | 32 | 33 | @pytest.mark.parametrize('login_data', [ 34 | {"username": '', "password": 'Whoops'}, 35 | {"username": '', "password": ''}, 36 | {"username": 'bad@example.com', "password": ''}, 37 | {"username": None, "password": None}, 38 | ]) 39 | def test_get_access_token_bad_input(login_data, client: TestClient, db_session: Session, default_user) -> None: 40 | '''Missing credentials should return HTTP 422''' 41 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) 42 | tokens = r.json() 43 | assert r.status_code == 422 44 | assert "access_token" not in tokens 45 | -------------------------------------------------------------------------------- /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 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date 15 | # within the migration file as well as the filename. 16 | # string value is passed to dateutil.tz.gettz() 17 | # leave blank for localtime 18 | # timezone = 19 | 20 | # max length of characters to apply to the 21 | # "slug" field 22 | # truncate_slug_length = 40 23 | 24 | # set to 'true' to run the environment during 25 | # the 'revision' command, regardless of autogenerate 26 | # revision_environment = false 27 | 28 | # set to 'true' to allow .pyc and .pyo files without 29 | # a source .py file to be detected as revisions in the 30 | # versions/ directory 31 | # sourceless = false 32 | 33 | # version location specification; this defaults 34 | # to alembic/versions. When using multiple version 35 | # directories, initial revisions must be specified with --version-path 36 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 37 | 38 | # the output encoding used when revision files 39 | # are written from script.py.mako 40 | # output_encoding = utf-8 41 | 42 | sqlalchemy.url = driver://user:pass@localhost/dbname 43 | 44 | 45 | [post_write_hooks] 46 | # post_write_hooks defines scripts or Python functions that are run 47 | # on newly generated revision scripts. See the documentation for further 48 | # detail and examples 49 | 50 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 51 | # hooks = black 52 | # black.type = console_scripts 53 | # black.entrypoint = black 54 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 55 | 56 | # Logging configuration 57 | [loggers] 58 | keys = root,sqlalchemy,alembic 59 | 60 | [handlers] 61 | keys = console 62 | 63 | [formatters] 64 | keys = generic 65 | 66 | [logger_root] 67 | level = WARN 68 | handlers = console 69 | qualname = 70 | 71 | [logger_sqlalchemy] 72 | level = WARN 73 | handlers = 74 | qualname = sqlalchemy.engine 75 | 76 | [logger_alembic] 77 | level = INFO 78 | handlers = 79 | qualname = alembic 80 | 81 | [handler_console] 82 | class = StreamHandler 83 | args = (sys.stderr,) 84 | level = NOTSET 85 | formatter = generic 86 | 87 | [formatter_generic] 88 | format = %(levelname)-5.5s [%(name)s] %(message)s 89 | datefmt = %H:%M:%S 90 | -------------------------------------------------------------------------------- /app/data/record_on_appeal/record_on_appeal.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from sqlalchemy import Boolean, Column, Integer, String, Table, ForeignKey 4 | from sqlalchemy.orm import relationship 5 | from sqlalchemy.sql.sqltypes import DateTime 6 | from ..database import mapper_registry 7 | from app.entities import RecordOnAppeal, RecordOnAppealDocketEntry 8 | 9 | roa_table = Table( 10 | 'records_on_appeal', 11 | mapper_registry.metadata, 12 | Column('id', Integer, primary_key=True, index=True), 13 | Column('original_case_id', Integer, ForeignKey('cases.id'), nullable=False), 14 | Column('title', String, nullable=False), 15 | Column('date_filed', DateTime), 16 | Column('sealed', Boolean, default=False), 17 | Column('type', String), 18 | Column('court', String), 19 | Column('receiving_court', String), 20 | Column('status', String, nullable=True), 21 | Column('reviewed', Boolean, default=False), 22 | Column('remanded', Boolean, default=False), 23 | Column('created_at', DateTime, default=datetime.datetime.utcnow), 24 | Column( 25 | 'updated_on', 26 | DateTime, 27 | default=datetime.datetime.utcnow, 28 | onupdate=datetime.datetime.utcnow 29 | ) 30 | ) 31 | 32 | roa_docket_entry_table = Table( 33 | "roa_docket_entry", 34 | mapper_registry.metadata, 35 | Column('id', Integer, nullable=False, primary_key=True), 36 | Column('case_id', Integer, ForeignKey('records_on_appeal.id'), nullable=False), 37 | Column('sequence_no', Integer, nullable=False), 38 | Column('court', String, nullable=False), 39 | Column('recieving_court', String, nullable=True), 40 | Column('text', String, nullable=False), 41 | Column('date_filed', DateTime), 42 | Column('entry_type', String, nullable=False), 43 | Column('sealed', Boolean, default=False), 44 | Column('include_with_appeal', Boolean, default=True), 45 | Column('created_at', DateTime, default=datetime.datetime.utcnow), 46 | Column( 47 | 'updated_on', 48 | DateTime, 49 | default=datetime.datetime.utcnow, 50 | onupdate=datetime.datetime.utcnow 51 | ) 52 | ) 53 | 54 | 55 | def run_mappers(): 56 | mapper_registry.map_imperatively(RecordOnAppealDocketEntry, roa_docket_entry_table) 57 | mapper_registry.map_imperatively( 58 | RecordOnAppeal, 59 | roa_table, 60 | properties={ 61 | 'docket_entries': relationship( 62 | RecordOnAppealDocketEntry, 63 | order_by="asc(RecordOnAppealDocketEntry.sequence_no)" 64 | ) 65 | } 66 | ) 67 | -------------------------------------------------------------------------------- /app/data/case/case.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from sqlalchemy import Boolean, Column, Integer, String, Table, ForeignKey 3 | from sqlalchemy.orm import relationship 4 | from sqlalchemy.sql.sqltypes import DateTime 5 | from ..database import mapper_registry 6 | from app.entities.case import Case, DocketEntry, DistrictCase, AppellateCase 7 | 8 | 9 | cases_table = Table( 10 | 'cases', 11 | mapper_registry.metadata, 12 | Column('id', Integer, primary_key=True, index=True), 13 | Column('title', String, nullable=False), 14 | Column('date_filed', DateTime), 15 | Column('sealed', Boolean, default=False), 16 | Column('type', String), 17 | Column('court', String), 18 | Column('status', String, nullable=True), 19 | Column('original_case_id', Integer), 20 | Column('reviewed', Boolean, default=False), 21 | Column('remanded', Boolean, default=False), 22 | Column('created_at', DateTime, default=datetime.datetime.utcnow), 23 | Column( 24 | 'updated_on', 25 | DateTime, 26 | default=datetime.datetime.utcnow, 27 | onupdate=datetime.datetime.utcnow 28 | ) 29 | ) 30 | 31 | docket_entry_table = Table( 32 | "docket_entries", 33 | mapper_registry.metadata, 34 | Column('id', Integer, nullable=False, primary_key=True), 35 | Column('case_id', Integer, ForeignKey('cases.id'), nullable=False), 36 | Column('sequence_no', Integer, nullable=False), 37 | Column('court', String), 38 | Column('text', String, nullable=False), 39 | Column('date_filed', DateTime), 40 | Column('entry_type', String, nullable=False), 41 | Column('sealed', Boolean, default=False), 42 | Column('created_at', DateTime, default=datetime.datetime.utcnow), 43 | Column( 44 | 'updated_on', 45 | DateTime, 46 | default=datetime.datetime.utcnow, 47 | onupdate=datetime.datetime.utcnow 48 | ) 49 | ) 50 | 51 | 52 | def run_mappers(): 53 | mapper_registry.map_imperatively(DocketEntry, docket_entry_table) 54 | 55 | mapper_registry.map_imperatively( 56 | Case, 57 | cases_table, 58 | polymorphic_on=cases_table.c.type, 59 | polymorphic_identity="case", 60 | properties={ 61 | 'docket_entries': relationship( 62 | DocketEntry, 63 | order_by="asc(DocketEntry.sequence_no)" 64 | 65 | ) 66 | } 67 | ) 68 | 69 | mapper_registry.map_imperatively( 70 | DistrictCase, 71 | inherits=Case, 72 | polymorphic_identity="district" 73 | ) 74 | 75 | mapper_registry.map_imperatively( 76 | AppellateCase, 77 | inherits=Case, 78 | polymorphic_identity="appellate" 79 | ) 80 | -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | from logging.config import fileConfig 4 | 5 | from sqlalchemy import engine_from_config 6 | from sqlalchemy import pool 7 | 8 | from alembic import context 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config # type: ignore 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) 17 | 18 | # add your model's MetaData object here 19 | # for 'autogenerate' support 20 | # from myapp import mymodel 21 | # target_metadata = mymodel.Base.metadata 22 | from app.data import mapper_registry # noqa 401 23 | 24 | target_metadata = mapper_registry.metadata 25 | 26 | # other values from the config, defined by the needs of env.py, 27 | # can be acquired: 28 | # my_important_option = config.get_main_option("my_important_option") 29 | # ... etc. 30 | from app.core.config import settings # noqa 401 31 | 32 | 33 | def get_db_url(): 34 | return settings.DATABASE_URL 35 | 36 | 37 | def run_migrations_offline(): 38 | """Run migrations in 'offline' mode. 39 | 40 | This configures the context with just a URL 41 | and not an Engine, though an Engine is acceptable 42 | here as well. By skipping the Engine creation 43 | we don't even need a DBAPI to be available. 44 | 45 | Calls to context.execute() here emit the given string to the 46 | script output. 47 | 48 | """ 49 | url = get_db_url() 50 | context.configure( # type: ignore 51 | url=url, 52 | target_metadata=target_metadata, 53 | literal_binds=True, 54 | dialect_opts={"paramstyle": "named"}, 55 | ) 56 | 57 | with context.begin_transaction(): # type: ignore 58 | context.run_migrations() # type: ignore 59 | 60 | 61 | def run_migrations_online(): 62 | """Run migrations in 'online' mode. 63 | 64 | In this scenario we need to create an Engine 65 | and associate a connection with the context. 66 | 67 | """ 68 | configuration = config.get_section(config.config_ini_section) 69 | configuration["sqlalchemy.url"] = get_db_url() 70 | connectable = engine_from_config( 71 | configuration, 72 | prefix="sqlalchemy.", 73 | poolclass=pool.NullPool, 74 | ) 75 | 76 | with connectable.connect() as connection: 77 | context.configure( 78 | connection=connection, target_metadata=target_metadata 79 | ) 80 | 81 | with context.begin_transaction(): 82 | context.run_migrations() 83 | 84 | 85 | if context.is_offline_mode(): 86 | run_migrations_offline() 87 | else: 88 | run_migrations_online() 89 | -------------------------------------------------------------------------------- /app/tests/integration/test_graphql.py: -------------------------------------------------------------------------------- 1 | import json 2 | from fastapi.testclient import TestClient 3 | from sqlalchemy.orm import Session 4 | 5 | headers = {"Content-Type": "application/json"} 6 | 7 | 8 | def test_basic_graphql_query(client: TestClient, db_session: Session, simple_case) -> None: 9 | '''It should return a case with only the requested fields''' 10 | query = { 11 | "query": "{case(id: %d) {title, type}}" % simple_case.id 12 | } 13 | 14 | r = client.post("/graphql/", data=json.dumps(query), headers=headers) 15 | assert r.status_code == 200 16 | resp = r.json() 17 | assert resp['data']['case']['title'] == 'Godzilla v. Mothra' 18 | assert resp['data']['case']['type'] == 'district' 19 | assert 'sealed' not in resp['data']['case'] 20 | 21 | 22 | def test_graphql_no_match_query(client: TestClient, db_session: Session, simple_case) -> None: 23 | '''It should return a case as none if not found''' 24 | query = { 25 | "query": "{case(id: 999) {title, type}}" 26 | } 27 | 28 | r = client.post("/graphql/", data=json.dumps(query), headers=headers) 29 | assert r.status_code == 200 30 | resp = r.json() 31 | assert resp['data']['case'] is None 32 | 33 | 34 | def test_graphql_invalid_query(client: TestClient, db_session: Session, simple_case) -> None: 35 | '''Given invalid an query, it should return http 400 with errors in result''' 36 | query = { 37 | "query": "{case(id: 999) {bad_field, type}}" 38 | } 39 | 40 | r = client.post("/graphql/", data=json.dumps(query), headers=headers) 41 | assert r.status_code == 400 42 | resp = r.json() 43 | assert 'data' not in resp 44 | assert resp['errors'] is not None 45 | 46 | 47 | def test_private_graphql_query_no_token(client: TestClient, db_session: Session, simple_case) -> None: 48 | '''It should return a 401 error when trying to reach private endpoint without a token''' 49 | query = { 50 | "query": "{case(id: %d) {title, type}}" % simple_case.id 51 | } 52 | 53 | r = client.post("/graphql_private/", data=json.dumps(query), headers=headers) 54 | assert r.status_code == 401 55 | 56 | 57 | def test_private_graphql_query_good_token(client: TestClient, db_session: Session, simple_case, admin_token) -> None: 58 | '''It should return data with a good token''' 59 | query = { 60 | "query": "{case(id: %d) {title, type}}" % simple_case.id 61 | } 62 | 63 | r = client.post( 64 | "/graphql/", 65 | data=json.dumps(query), 66 | headers={**headers, 'Authorization': f'Bearer {admin_token}'} 67 | ) 68 | assert r.status_code == 200 69 | resp = r.json() 70 | assert resp['data']['case']['title'] == 'Godzilla v. Mothra' 71 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '44 2 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /alembic/versions/2c9cf71649e2_add_tables_for_roa.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | """add tables for ROA 4 | 5 | Revision ID: 2c9cf71649e2 6 | Revises: 5c02d93ce254 7 | Create Date: 2021-08-10 11:26:10.569322 8 | 9 | """ 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = '2c9cf71649e2' 16 | down_revision = '5c02d93ce254' 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table('records_on_appeal', 24 | sa.Column('id', sa.Integer(), nullable=False), 25 | sa.Column('original_case_id', sa.Integer(), nullable=False), 26 | sa.Column('title', sa.String(), nullable=False), 27 | sa.Column('date_filed', sa.DateTime(), nullable=True), 28 | sa.Column('sealed', sa.Boolean(), nullable=True), 29 | sa.Column('type', sa.String(), nullable=True), 30 | sa.Column('court', sa.String(), nullable=True), 31 | sa.Column('receiving_court', sa.String(), nullable=True), 32 | sa.Column('status', sa.String(), nullable=True), 33 | sa.Column('reviewed', sa.Boolean(), nullable=True), 34 | sa.Column('remanded', sa.Boolean(), nullable=True), 35 | sa.Column('created_at', sa.DateTime(), nullable=True), 36 | sa.Column('updated_on', sa.DateTime(), nullable=True), 37 | sa.ForeignKeyConstraint(['original_case_id'], ['cases.id'], ), 38 | sa.PrimaryKeyConstraint('id') 39 | ) 40 | op.create_index(op.f('ix_records_on_appeal_id'), 'records_on_appeal', ['id'], unique=False) 41 | op.create_table('roa_docket_entry', 42 | sa.Column('id', sa.Integer(), nullable=False), 43 | sa.Column('case_id', sa.Integer(), nullable=False), 44 | sa.Column('sequence_no', sa.Integer(), nullable=False), 45 | sa.Column('court', sa.String(), nullable=False), 46 | sa.Column('recieving_court', sa.String(), nullable=True), 47 | sa.Column('text', sa.String(), nullable=False), 48 | sa.Column('date_filed', sa.DateTime(), nullable=True), 49 | sa.Column('entry_type', sa.String(), nullable=False), 50 | sa.Column('sealed', sa.Boolean(), nullable=True), 51 | sa.Column('include_with_appeal', sa.Boolean(), nullable=True), 52 | sa.Column('created_at', sa.DateTime(), nullable=True), 53 | sa.Column('updated_on', sa.DateTime(), nullable=True), 54 | sa.ForeignKeyConstraint(['case_id'], ['records_on_appeal.id'], ), 55 | sa.PrimaryKeyConstraint('id') 56 | ) 57 | # ### end Alembic commands ### 58 | 59 | 60 | def downgrade(): 61 | # ### commands auto generated by Alembic - please adjust! ### 62 | op.drop_table('roa_docket_entry') 63 | op.drop_index(op.f('ix_records_on_appeal_id'), table_name='records_on_appeal') 64 | op.drop_table('records_on_appeal') 65 | # ### end Alembic commands ### 66 | -------------------------------------------------------------------------------- /app/tests/integration/test_create_roa.py: -------------------------------------------------------------------------------- 1 | import json 2 | from fastapi.testclient import TestClient 3 | from sqlalchemy.orm import Session 4 | 5 | headers = {"Content-Type": "application/json"} 6 | 7 | 8 | def test_create_roa_mutation(client: TestClient, db_session: Session, simple_case) -> None: 9 | '''It should return a createRecordOnAppeal object with only the requested fields''' 10 | query = { 11 | "query": "mutation{createRecordOnAppeal(caseId: %d) {title, originalCaseId}}" % simple_case.id 12 | } 13 | r = client.post("/graphql/", data=json.dumps(query), headers=headers) 14 | assert r.status_code == 200 15 | resp = r.json() 16 | assert resp['data']['createRecordOnAppeal']['originalCaseId'] == simple_case.id 17 | assert resp['data']['createRecordOnAppeal']['title'] == simple_case.title 18 | 19 | 20 | def test_create_roa_mutation_persists(client: TestClient, db_session: Session, simple_case) -> None: 21 | '''It should add a record of appeal to the sysyem''' 22 | mutation = { 23 | "query": "mutation{createRecordOnAppeal(caseId: %d) {id, title, originalCaseId}}" % simple_case.id 24 | } 25 | r = client.post("/graphql/", data=json.dumps(mutation), headers=headers) 26 | resp = r.json() 27 | roa_id = resp['data']['createRecordOnAppeal']['id'] 28 | 29 | query = { 30 | "query": "{recordOnAppeal(id: %s) {id, title, originalCaseId}}" % roa_id 31 | } 32 | r = client.post("/graphql/", data=json.dumps(query), headers=headers) 33 | assert r.status_code == 200 34 | resp = r.json() 35 | assert resp['data'] == { 36 | 'recordOnAppeal': { 37 | 'id': roa_id, 38 | 'title': simple_case.title, 39 | 'originalCaseId': simple_case.id 40 | } 41 | } 42 | 43 | 44 | def test_send_roa_persists(client: TestClient, db_session: Session, simple_case) -> None: 45 | '''It should add a record of appeal to the sysyem''' 46 | create_mutation = { 47 | "query": "mutation{createRecordOnAppeal(caseId: %d) {id, title, originalCaseId}}" % simple_case.id 48 | } 49 | r = client.post("/graphql/", data=json.dumps(create_mutation), headers=headers) 50 | resp = r.json() 51 | roa_id = resp['data']['createRecordOnAppeal']['id'] 52 | 53 | send_mutation = { 54 | "query": ''' 55 | mutation{sendRecordOnAppeal(recordOnAppealId: %s, receivingCourtId: "ca9") { 56 | id, 57 | receivingCourt{ 58 | id 59 | } }}''' % roa_id 60 | } 61 | 62 | r = client.post("/graphql/", data=json.dumps(send_mutation), headers=headers) 63 | assert r.status_code == 200 64 | resp = r.json() 65 | assert resp['data'] == { 66 | 'sendRecordOnAppeal': { 67 | 'id': roa_id, 68 | 'receivingCourt': {'id': 'ca9'} 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /doc/adr/0003-python.md: -------------------------------------------------------------------------------- 1 | # 1. Using Python on the backend 2 | 3 | Date: 2021-08-09 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | To support the E&I phase of 18F's work with the Administrative Office of the Courts we need to build a small API that is easy to maintain, easy to deploy, and flexible. Naturally, one of the first (and sometimes consequential) decisions we will make is the choice of programming language. Considerations for this choice include: 12 | 13 | - Supported by cloud services without a lot of custom work 14 | - Well-understood by people on the team with easily available documentation 15 | - Mature enough to have all the tools and libraries we will need 16 | - Sufficient concurrency model for designing and API 17 | 18 | ## Decision 19 | 20 | For the current work we will build the API using Python and related tools such as FastAPI, Pytest, and SQLAlchemy. 21 | 22 | ## Consequences 23 | 24 | The original Path Analysis pointed to CM/ECF's use of perl as problematic because: 25 | 26 | > it is a dynamically-typed scripting language, which means certain types of errors that are easy to detect in other languages are very difficult to detect safely in Perl. 27 | 28 | Two natural choices for designing this API: Python and Javascript are also dynamically typed. However both offer typing systems — Typescript with Javascript, and optional typing with Python — that address some of the problems associated with dynamic typing. Both are also extremely popular with developers. Most statically typed languages such as C++, Java, or Scala are poor choices for this phase of work because they bring significant overhead in tooling, many fewer developers are fluent in them, and they are not nearly as easy to deploy to cloud providers. 29 | 30 | Python is fully-featured and includes excellent libraries for testing, validation, database interaction, and serving web requests. It also offers a multi-paradigm programming model so developers can freely mix object-oriented and functional styles where appropriate allowing developers a great deal of flexibility. Python is also fully supported and used frequently on cloud providers including Cloud.gov, AWS, and Google Cloud Services. Most importantly, a LOT of people know Python and use it daily. 31 | 32 | A language's concurrency model should be a consideration when developing an application with intense I/O work such as querying a database while serving incoming networking requests. Python's support for concurrent programming is adequate but can be confusing since it offers concurrency models based on processes, threads, and asyncio coroutines or any combination thereof. If a decision is made to move forward with a larger project, a language like Go, built from the ground up with concurrency in mind, may be worth considering. It is also strongly-typed, which would address the path analysis's recommendation. 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | from app.core.enums import CaseStatus 2 | from typing import Generator, Any 3 | from datetime import datetime 4 | import pytest 5 | from sqlalchemy import create_engine 6 | from sqlalchemy.orm import Session 7 | from fastapi import FastAPI 8 | from fastapi.testclient import TestClient 9 | from app.main import app 10 | from app.core.config import settings 11 | from app.data.database import mapper_registry, get_db 12 | from sqlalchemy.orm import sessionmaker 13 | from app.entities import User, DistrictCase 14 | from app.core.security import get_password_hash, create_access_token 15 | 16 | engine = create_engine(settings.DATABASE_URL_TEST) 17 | TestSession = sessionmaker(autocommit=False, autoflush=False, bind=engine) 18 | 19 | 20 | @pytest.fixture(autouse=True, scope="package") 21 | def api_app() -> Generator[FastAPI, Any, None]: 22 | '''Set up tables for the tests''' 23 | mapper_registry.metadata.create_all(engine) 24 | yield app 25 | mapper_registry.metadata.drop_all(engine) 26 | 27 | 28 | @pytest.fixture 29 | def db_session(api_app: FastAPI) -> Generator[Session, Any, None]: 30 | '''Create a fresh session inside a transacation and roll it back after the test''' 31 | connection = engine.connect() 32 | transaction = connection.begin() 33 | session = TestSession(bind=connection) 34 | 35 | yield session 36 | 37 | session.close() 38 | transaction.rollback() 39 | connection.close() 40 | 41 | 42 | @pytest.fixture() 43 | def client(api_app: FastAPI, db_session: Session) -> Generator[TestClient, Any, None]: 44 | '''Uses FastAPIs dependency injection to replace the DB dependency everywhere''' 45 | 46 | def get_test_db(): 47 | try: 48 | yield db_session 49 | finally: 50 | pass 51 | 52 | app.dependency_overrides[get_db] = get_test_db 53 | with TestClient(app) as client: 54 | yield client 55 | 56 | 57 | @pytest.fixture() 58 | def default_user(db_session: Session): 59 | hashed_password = get_password_hash(settings.INITIAL_ADMIN_PASSWORD) 60 | 61 | test_user = User( 62 | email=settings.INITIAL_ADMIN_USER, 63 | hashed_password=hashed_password, 64 | full_name="Test User", 65 | username="user.test", 66 | roles=[] 67 | ) 68 | 69 | db_session.add(test_user) 70 | db_session.commit() 71 | return test_user 72 | 73 | 74 | @pytest.fixture() 75 | def simple_case(db_session: Session): 76 | case_in = DistrictCase( 77 | title="Godzilla v. Mothra", 78 | date_filed=datetime.now(), 79 | status=CaseStatus.new, 80 | sealed=True, 81 | court="tnmd", 82 | docket_entries=[] 83 | ) 84 | db_session.add(case_in) 85 | db_session.commit() 86 | return case_in 87 | 88 | 89 | @pytest.fixture() 90 | def admin_token(): 91 | return create_access_token('1') 92 | -------------------------------------------------------------------------------- /doc/adr/0002-graphql.md: -------------------------------------------------------------------------------- 1 | # 1. Using GraphQL for the API Layer 2 | 3 | Date: 2021-08-09 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | During the E&I phases of work for the Administrative Office of the Courts we are exploring the use of an API as a centralized source of truth for court data both as a means to access the data as well as manipulate it. One of primary challenges faced by the AO is the diverse ways different courts access and use data in their day-to-day work. Different courts have different conventions and rules for processing cases and it has been difficult to design a single software solution for managing their cases. 18F's Path Analysis recommended using an API as a central tool that can offer flexible access to data, while still allowing courts autonomy over how this data is used. 12 | 13 | The convention of the REST APIs is battle-tested and well-understood. REST APIs define resources as HTTP endpoints and associates them with HTTP verbs such as GET, POST, PUT to define actions. However, REST APIs present some difficulties: 14 | 15 | - They are difficult to change once clients start using them, leading to simultaneously maintaining various versions of the API 16 | - It is usually not feasible to dynamically define what data is returned from the API, leaving clients with either more data than they need because that's what the endpoint returns, or requiring them to make multiple requests for related data. 17 | 18 | GraphQL is a different API paradigm designed specifically to address issues of efficiency and flexibility. 19 | 20 | ## Decision 21 | 22 | For this phase of work the primary API interface will be GraphQL, although the domain logic of the application should be independent enough to allow this to change in the future. 23 | 24 | ## Consequences 25 | 26 | GraphQL moves many of the difficulties of processing data from the frontend to the backend. With a traditional REST API, the front-end code often accepts whatever form of data the API sends and then is left to manipulate, merge, and filter this data to suit the needs of the user. With GraphQL the front-end can define with some precision exactly what data it needs and what shape that data should take. Also, because a schema is central to GraphQL, front-end developers can discover specifically what type of data is available making data validation easier. For the front-end developer accustomed to REST, a GraphQL API will require some additional learning. 27 | 28 | The work of serving a GraphQL API however, is more difficult on the backend. Rather than defining specific endpoints that send specific data, the GraphQL server needs to parse JSON queries that take a variety forms and can mix and match data potentially from many sources. This presents some n+1 efficiency problems when a single request from a user leads to many requests to a database. This issue is manageable with tools such as DataLoader layer and through smart caching of results within a single query. We have not, however, explored using this yet since our API is currently simple enough to not need them. 29 | -------------------------------------------------------------------------------- /app/api/graph_ql/resolvers/mutation_resolvers.py: -------------------------------------------------------------------------------- 1 | from ariadne import MutationType 2 | from app.core.enums import CaseStatus 3 | from app.data import case, record_on_appeal, record_on_appeal_docket_entry 4 | from app.entities import AppellateCase, Court 5 | 6 | mutation = MutationType() 7 | 8 | 9 | @mutation.field("sealCase") 10 | def resolve_seal_case(obj, info, caseId, sealed): 11 | session = info.context['request'].state.db 12 | original_case = case.get(session, id=caseId) 13 | if original_case is None: 14 | return 15 | original_case.seal(sealed) 16 | case.add(session, original_case) 17 | session.commit() 18 | return original_case 19 | 20 | 21 | @mutation.field("createAppealCase") 22 | def create_appeal_case(obj, info, caseId, receivingCourtId=None): 23 | session = info.context['request'].state.db 24 | original_case = case.get(session, id=caseId) 25 | if original_case is None: 26 | raise ValueError(f"Could not find case with id: {caseId}") 27 | 28 | modified_case = AppellateCase.from_district_case(original_case) 29 | 30 | if modified_case is not None: 31 | original_case.status = CaseStatus.on_appeal 32 | case.add(session, original_case) 33 | case.add(session, modified_case) 34 | session.commit() 35 | return modified_case 36 | 37 | 38 | @mutation.field("createRecordOnAppeal") 39 | def create_roa(obj, info, caseId): 40 | session = info.context['request'].state.db 41 | original_case = case.get(session, id=caseId) 42 | if original_case is None: 43 | raise ValueError(f"Could not find case with id: {caseId}") 44 | 45 | roa = original_case.create_record_on_appeal() 46 | 47 | if roa is not None: 48 | case.add(session, original_case) 49 | record_on_appeal.add(session, roa) 50 | session.commit() 51 | return roa 52 | 53 | 54 | @mutation.field("sendRecordOnAppeal") 55 | def send_roa(obj, info, recordOnAppealId, receivingCourtId): 56 | session = info.context['request'].state.db 57 | roa = record_on_appeal.get(session, id=recordOnAppealId) 58 | if roa is None: 59 | raise ValueError(f"Could not find record on appeal with id: {recordOnAppealId}") 60 | receivingCourt = Court.from_id(receivingCourtId) 61 | if receivingCourt is None: 62 | raise ValueError(f"Could not find court with id: {receivingCourtId}") 63 | roa.send_to_court(receivingCourt) 64 | record_on_appeal.add(session, roa) 65 | session.commit() 66 | return roa 67 | 68 | 69 | @mutation.field("editRecordOnAppealItem") 70 | def edit_roa_item(obj, info, docketEntry): 71 | session = info.context['request'].state.db 72 | docket = record_on_appeal_docket_entry.get(session, id=docketEntry['id']) 73 | if docket is None: # TODO this should be an exception 74 | raise ValueError(f"Could not find entry with id: {docketEntry['id']}") 75 | if 'sealed' in docketEntry: 76 | docket.sealed = docketEntry['sealed'] 77 | if 'includeWithAppeal' in docketEntry: 78 | docket.include_with_appeal = docketEntry['includeWithAppeal'] 79 | record_on_appeal_docket_entry.add(session, docket) 80 | session.commit() 81 | return docket 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Administrative Office of the Courts E&I 2 | 3 | This is a small API service to support the AO's E&I. 4 | 5 | ## Requirements 6 | To run locally you will need access to Python 3.6 or higher. You will also need access to a database like PostgreSQL, although SQLite, available on most systems, will work for local development and tests. 7 | 8 | For development, some familiarity with the following libraries will be helpful: 9 | - [SLQAlchemy](https://www.sqlalchemy.org): Database ORM (with the help of [Alembic](https://alembic.sqlalchemy.org/en/latest/) for DB migrations) 10 | - [Ariadne](https://ariadne.readthedocs.io/en/0.3.0/): provides GraphQL support 11 | - [FastAPI](https://fastapi.tiangolo.com): Web/API framework 12 | - [Pydantic](https://pydantic-docs.helpmanual.io): Data validation and managing configuration/settings 13 | 14 | 15 | ## Installing locally 16 | To avoid installing dependencies in your global environment, create a virtual environment. You can replace `.venv` to any path where you want the environment files to live (just make sure you `source` the right place in the next step). However, placing it in the root directory of the project and naming it `.venv` will make some editors like VSCode load it automatically. 17 | 18 | ```console 19 | $ python3 -m venv .venv 20 | $ source .venv/bin/activate 21 | ``` 22 | 23 | Install requirement into virtual env. 24 | 25 | ```console 26 | $ pip install -r requirements.txt 27 | ``` 28 | 29 | If this step gives you errors, make sure you are using a recent version of `pip`. You can make sure your venv has an up-to-date `pip` install with: `pip install -U pip`. 30 | 31 | To exit the virtual environment and get back to your original python env: 32 | 33 | ```console 34 | $ deactivate 35 | ``` 36 | 37 | ## Environment 38 | 39 | The app expects to find a few environmental variables. An easy way to provide these is by creating a file called `.env` in the root directory and add them there. 40 | 41 | SECRET_KEY=some_good_secret_for_signing_tokens 42 | DATABASE_URL=postgres://localhost:5432/some_database 43 | DATABASE_URL_TEST=postgresql://localhost:5432/some_test_database 44 | INITIAL_ADMIN_USER=initial_admin 45 | INITIAL_ADMIN_PASSWORD=their_password 46 | 47 | Default values for these will be created in `app/core/config.py` but those defaults are probably not what you want. 48 | ## Initialize the database 49 | 50 | This is designed to run Postgres, but should run on any database, including SQLite, that SQLAlchemy supports. 51 | 52 | The settings for the app will expect to find environmental variables for `DATABASE_URL` and `DATABASE_URL_TEST` containing connection strings to the databases. The test database is a convenience to allow you to run integration tests without messing up your seed data. 53 | 54 | Creating and maintaining the database is currently done with alembic. Initial migrations are in `alembic/versions`. To run these use: 55 | 56 | ```console 57 | $ alembic upgrade head 58 | ``` 59 | 60 | Future changes to the database schema should create further migrations. To create a migration file you can use alembic: 61 | 62 | ```console 63 | $ alembic revision -m "some note about this migration" 64 | ``` 65 | 66 | Future changes to the database schema should create further migrations. 67 | 68 | ## Starting 69 | 70 | **In Development:** 71 | From the root directory run: 72 | 73 | ```console 74 | $ uvicorn app.main:app 75 | ``` 76 | 77 | To auto-reload on change use: 78 | 79 | ```console 80 | $ uvicorn app.main:app --reload 81 | ``` 82 | -------------------------------------------------------------------------------- /app/entities/case.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List, Literal, Union, Optional, TypeVar 3 | from dataclasses import dataclass, field 4 | 5 | from .docket_entry import DocketEntry 6 | from app.core.enums import CourtType, CaseStatus 7 | from .court import Court 8 | from .case_entity import CaseEntity 9 | from .record_on_appeal import RecordOnAppeal, RecordOnAppealDocketEntry 10 | 11 | T = TypeVar('T', bound='Case') 12 | 13 | 14 | @dataclass 15 | class Case(CaseEntity): 16 | title: str 17 | date_filed: datetime.date 18 | status: Optional[str] 19 | 20 | def seal(self: T, sealed: bool) -> T: 21 | self.sealed = sealed 22 | return self 23 | 24 | def create_record_on_appeal(self, receiving_court: Optional[Court] = None): 25 | pass 26 | 27 | 28 | @dataclass 29 | class DistrictCase(Case): 30 | docket_entries: List[DocketEntry] = field(default_factory=list) 31 | type: Literal[CourtType.district] = field(init=False, default=CourtType.district) 32 | sealed: bool = False 33 | 34 | def create_record_on_appeal(self, receiving_court: Optional[Court] = None) -> RecordOnAppeal: 35 | self.validate_appeal(receiving_court) 36 | self.status = CaseStatus.submitted_for_appeal 37 | return RecordOnAppeal( 38 | original_case_id=self.id, 39 | docket_entries=list(map(RecordOnAppealDocketEntry.from_docket_entry, self.docket_entries)), 40 | title=self.title, 41 | status=CaseStatus.submitted_for_appeal, 42 | receiving_court=receiving_court.id if receiving_court else None, 43 | court=self.court, 44 | sealed=self.sealed, 45 | date_filed=datetime.datetime.now(), 46 | ) 47 | 48 | def validate_appeal(self, court: Optional[Court]) -> None: 49 | if self.status == CaseStatus.on_appeal or self.status == CaseStatus.submitted_for_appeal: 50 | raise ValueError(f"A record of appeal has already been created for Case {self.id}") 51 | if court and court.type != CourtType.appellate: 52 | raise ValueError(f"Can not appeal to {court.full_name}") 53 | 54 | 55 | @dataclass 56 | class BankruptcyCase(Case): 57 | docket_entries: List[DocketEntry] = field(default_factory=list) 58 | type: Literal[CourtType.bankruptcy] = field(init=False, default=CourtType.bankruptcy) 59 | sealed: bool = False 60 | 61 | def validate_appeal(self, court: Court) -> None: 62 | pass 63 | 64 | 65 | @dataclass 66 | class AppellateCase(Case): 67 | original_case_id: int 68 | docket_entries: List[DocketEntry] = field(default_factory=list) 69 | type: Literal[CourtType.appellate] = field(init=False, default=CourtType.appellate) 70 | sealed: bool = False 71 | reviewed: bool = False 72 | remanded: bool = False 73 | 74 | def validate_appeal(self, court: Court) -> None: 75 | pass 76 | 77 | @classmethod 78 | def from_district_case(cls, district_case, receiving_court_id: Optional[str] = None): 79 | '''Create a new appellate case from a district case''' 80 | if receiving_court_id is None: 81 | receiving_court = Court.from_id(district_case.court).parent_court() 82 | if receiving_court is None: 83 | raise ValueError("Can not determine appelate court") 84 | else: 85 | receiving_court = Court.from_id(receiving_court_id) 86 | 87 | district_case.validate_appeal(receiving_court) 88 | 89 | appellate_case = cls( 90 | original_case_id=district_case.id, 91 | docket_entries=[d.copy() for d in district_case.docket_entries], 92 | title=district_case.title, 93 | status=CaseStatus.submitted_for_appeal, 94 | court=receiving_court.id, 95 | sealed=district_case.sealed, 96 | date_filed=datetime.datetime.now(), 97 | 98 | ) 99 | return appellate_case 100 | 101 | 102 | CaseType = Union[DistrictCase, AppellateCase, BankruptcyCase] 103 | -------------------------------------------------------------------------------- /app/api/graph_ql/schemas/case.graphql: -------------------------------------------------------------------------------- 1 | scalar Datetime 2 | 3 | type Query { 4 | case(id: Int!): Case 5 | court(id: String!): Court 6 | recordOnAppeal(id: Int!): RecordOnAppeal 7 | currentuser: User 8 | } 9 | 10 | input RecordOnAppealDocketEntryInput { 11 | id: ID! 12 | sealed: Boolean 13 | includeWithAppeal: Boolean 14 | } 15 | 16 | type Mutation { 17 | """ 18 | Sets the sealed status of the case with the given ID 19 | Returns: 20 | – the modified case 21 | – or null if the case was not found 22 | """ 23 | sealCase(caseId: Int!, sealed: Boolean): Case 24 | 25 | """ 26 | Resets the testing database's seed data back to it's initial state 27 | Any mutations made to the data will be reset. 28 | """ 29 | resetSeedData: Boolean 30 | """ 31 | Send a case with the given caseId to the appellate court. 32 | If recievingCourtId is null, the appellate court will be sent to the 33 | circuit court where the original case it. 34 | Returns: 35 | - The appellate case 36 | Errors: 37 | – Case cannot be found 38 | - receivingCourt can not be determined 39 | - Case has already been sent to appellate 40 | """ 41 | createAppealCase(caseId: Int!, receivingCourtId: String): Case 42 | 43 | """ 44 | Create a Record on Appeal for the given case. It will not be accessible 45 | to the receiving court until it the receiving court it set with 46 | sendRecordOnAppeal 47 | Returns: 48 | - The record on appeal object 49 | 50 | """ 51 | createRecordOnAppeal(caseId: Int!): RecordOnAppeal 52 | """ 53 | Sets the appeal court for this record. This should give the appeal court 54 | access to the record. 55 | Returns: 56 | - The record on appeal object 57 | """ 58 | sendRecordOnAppeal(recordOnAppealId: Int!, receivingCourtId: String!): RecordOnAppeal 59 | 60 | editRecordOnAppealItem(docketEntry:RecordOnAppealDocketEntryInput!): RecordOnAppealDocketEntry 61 | } 62 | 63 | enum CourtType{ 64 | district 65 | appellate 66 | bankruptcy 67 | } 68 | 69 | type RecordOnAppeal { 70 | id: ID! 71 | title: String! 72 | originalCaseId: Int! 73 | createdAt: Datetime! 74 | updatedOn: Datetime 75 | docketEntries: [RecordOnAppealDocketEntry!]! 76 | sealed: Boolean 77 | court: Court! 78 | receivingCourt: Court 79 | } 80 | 81 | type RecordOnAppealDocketEntry { 82 | id: ID! 83 | text: String! 84 | sequenceNumber: Int! 85 | dateFiled: Datetime 86 | entryType: String! 87 | sealed: Boolean! 88 | includeWithAppeal: Boolean! 89 | } 90 | 91 | interface Case { 92 | id: ID! 93 | title: String! 94 | createdAt: Datetime! 95 | updatedOn: Datetime 96 | docketEntries: [DocketEntry!]! 97 | type: CourtType! 98 | sealed: Boolean 99 | court: Court! 100 | status: String 101 | } 102 | 103 | type DistrictCase implements Case { 104 | id: ID! 105 | title: String! 106 | createdAt: Datetime! 107 | updatedOn: Datetime 108 | docketEntries: [DocketEntry!]! 109 | type: CourtType! 110 | sealed: Boolean 111 | court: Court! 112 | status: String 113 | } 114 | 115 | type AppellateCase implements Case { 116 | id: ID! 117 | title: String! 118 | createdAt: Datetime! 119 | updatedOn: Datetime 120 | docketEntries: [DocketEntry!]! 121 | type: CourtType! 122 | sealed: Boolean 123 | originalCaseId: Int 124 | court: Court! 125 | status: String 126 | } 127 | 128 | type DocketEntry { 129 | text: String! 130 | sequenceNumber: Int! 131 | dateFiled: Datetime 132 | entryType: String! 133 | sealed: Boolean! 134 | } 135 | 136 | type Court{ 137 | id: String! 138 | type: CourtType! 139 | short_name: String! 140 | full_name: String! 141 | parent: String 142 | lowerCourts: [Court]! 143 | } 144 | 145 | type User { 146 | id: ID! 147 | full_name: String 148 | username: String 149 | roles: [Role]! 150 | court: Court 151 | } 152 | 153 | type Role { 154 | id: ID! 155 | rolename: String 156 | } -------------------------------------------------------------------------------- /alembic/versions/179a279f5647_init_db.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | """ 3 | 4 | init db 5 | 6 | Revision ID: 179a279f5647 7 | Revises: 8 | Create Date: 2021-07-20 13:51:34.235336 9 | 10 | """ 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision = '179a279f5647' 17 | down_revision = None 18 | branch_labels = None 19 | depends_on = None 20 | 21 | 22 | def upgrade(): 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.create_table( 25 | 'cases', 26 | sa.Column('created_at', sa.DateTime(), nullable=True), 27 | sa.Column('updated_on', sa.DateTime(), nullable=True), 28 | sa.Column('id', sa.Integer(), nullable=False), 29 | sa.Column('title', sa.String(), nullable=False), 30 | sa.Column('date_filed', sa.DateTime(), nullable=True), 31 | sa.Column('sealed', sa.Boolean(), nullable=True), 32 | sa.Column('type', sa.String(), nullable=True), 33 | sa.Column('original_case_id', sa.Integer(), nullable=True), 34 | sa.Column('reviewed', sa.Boolean(), nullable=True), 35 | sa.Column('remanded', sa.Boolean(), nullable=True), 36 | sa.PrimaryKeyConstraint('id') 37 | ) 38 | op.create_index(op.f('ix_cases_id'), 'cases', ['id'], unique=False) 39 | op.create_table( 40 | 'roles', 41 | sa.Column('id', sa.Integer(), nullable=False), 42 | sa.Column('rolename', sa.String(), nullable=True), 43 | sa.PrimaryKeyConstraint('id') 44 | ) 45 | op.create_index(op.f('ix_roles_id'), 'roles', ['id'], unique=False) 46 | op.create_index(op.f('ix_roles_rolename'), 'roles', ['rolename'], unique=True) 47 | op.create_table( 48 | 'users', 49 | sa.Column('created_at', sa.DateTime(), nullable=True), 50 | sa.Column('updated_on', sa.DateTime(), nullable=True), 51 | sa.Column('id', sa.Integer(), nullable=False), 52 | sa.Column('username', sa.String(), nullable=True), 53 | sa.Column('email', sa.String(), nullable=True), 54 | sa.Column('full_name', sa.String(), nullable=True), 55 | sa.Column('hashed_password', sa.String(), nullable=True), 56 | sa.Column('is_active', sa.Boolean(), nullable=True), 57 | sa.PrimaryKeyConstraint('id') 58 | ) 59 | op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) 60 | op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) 61 | op.create_table( 62 | 'docket_entries', 63 | sa.Column('created_at', sa.DateTime(), nullable=True), 64 | sa.Column('updated_on', sa.DateTime(), nullable=True), 65 | sa.Column('id', sa.Integer(), nullable=False), 66 | sa.Column('case_id', sa.Integer(), nullable=False), 67 | sa.Column('sequence_no', sa.Integer(), nullable=False), 68 | sa.Column('text', sa.String(), nullable=False), 69 | sa.Column('date_filed', sa.DateTime(), nullable=True), 70 | sa.Column('entry_type', sa.String(), nullable=False), 71 | sa.Column('sealed', sa.Boolean(), nullable=True), 72 | sa.ForeignKeyConstraint(['case_id'], ['cases.id'], ), 73 | sa.PrimaryKeyConstraint('id') 74 | ) 75 | op.create_table( 76 | 'user_roles', 77 | sa.Column('user_id', sa.Integer(), nullable=True), 78 | sa.Column('role_id', sa.Integer(), nullable=True), 79 | sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'), 80 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE') 81 | ) 82 | # ### end Alembic commands ### 83 | 84 | 85 | def downgrade(): 86 | # ### commands auto generated by Alembic - please adjust! ### 87 | op.drop_table('user_roles') 88 | op.drop_table('docket_entries') 89 | op.drop_index(op.f('ix_users_id'), table_name='users') 90 | op.drop_index(op.f('ix_users_email'), table_name='users') 91 | op.drop_table('users') 92 | op.drop_index(op.f('ix_roles_rolename'), table_name='roles') 93 | op.drop_index(op.f('ix_roles_id'), table_name='roles') 94 | op.drop_table('roles') 95 | op.drop_index(op.f('ix_cases_id'), table_name='cases') 96 | op.drop_table('cases') 97 | # ### end Alembic commands ### 98 | -------------------------------------------------------------------------------- /seed_data/case.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Wilson v. Chaney", 4 | "date_filed": "2007-05-03", 5 | "court": "tnmd", 6 | "status": "new", 7 | "docket_entries":[ 8 | {"text": "ANSWER to Complaint by Marcia Chaney. (SGP)", "sequence_no": 7, "date_filed": "2007-03-05", "case_id": "13", "sealed": false, "entry_type": "answer"}, 9 | {"text": "Summons Issued as to Marcia Chaney. (SGP)", "sequence_no": 9, "date_filed": "2007-03-05", "case_id": "13", "sealed": false, "entry_type": "service"}, 10 | {"text": "Minute Entry for proceedings held before Judge Nathan J Nine:. Admissions due by 3/7/2007. Contempt Hearing set for 3/8/2007 01:30 PM in Courtroom 1 before Samantha C Sigma. Discovery due by 3/9/2007. Motions due by 3/12/2007. (SGP)", "sequence_no": 11, "date_filed": "2007-03-05", "case_id": "13", "sealed": false, "entry_type": "minute"}, 11 | {"text": "ANSWER to Complaint with Jury Demand by Marcia Chaney. (SGP)", "sequence_no": 13, "date_filed": "2007-03-05", "case_id": "13", "sealed": false, "entry_type": "answer"}, 12 | {"text": "MOTION to Appoint Expert by Marcia Chaney. Motions referred to Nathan J Nine. (SGP)", "sequence_no": 15, "date_filed": "2007-03-05", "case_id": "13", "sealed": false, "entry_type": "motion"}, 13 | {"text": "RESPONSE to Motion re [4] MOTION to Appoint Expert filed by Cheryl Anne Wilson. (SGP)", "sequence_no": 17, "date_filed": "2007-03-05", "case_id": "13", "sealed": false, "entry_type": "resp"}, 14 | {"text": "MOTION to Produce by Cheryl Anne Wilson. Motions referred to Nathan J Nine. (SGP)", "sequence_no": 20, "date_filed": "2007-03-05", "case_id": "13", "sealed": false, "entry_type": "motion"}, 15 | {"text": "MOTION to Dismiss by Marcia Chaney. Motions referred to Nathan J Nine. Responses due by 3/9/2007 (SGP)", "sequence_no": 22, "date_filed": "2007-03-05", "case_id": "13", "sealed": false, "entry_type": "motion"}, 16 | {"text": "MOTION for Discovery by Cheryl Anne Wilson. Motions referred to Nathan J Nine. (SGP)", "sequence_no": 24, "date_filed": "2007-03-07", "case_id": "13", "sealed": false, "entry_type": "motion"}, 17 | {"text": "MOTION to Appoint Expert by Marcia Chaney. Motions referred to Nathan J Nine. (SGP)", "sequence_no": 26, "date_filed": "2007-03-07", "case_id": "13", "sealed": false, "entry_type": "motion"}, 18 | {"text": "ORDER denying [7] Motion to Dismiss (SGP)", "sequence_no": 28, "date_filed": "2007-03-07", "case_id": "13", "sealed": false, "entry_type": "order"}, 19 | {"text": "ORDER denying [4] Motion to Appoint Expert; denying [9] Motion to Appoint Expert (SGP)", "sequence_no": 30, "date_filed": "2007-03-07", "case_id": "13", "sealed": false, "entry_type": "order"}, 20 | {"text": "Minute Entry for proceedings held before Judge Nathan J Nine:. Cross Motions due by 3/14/2007. Discovery due by 3/16/2007. Motions due by 3/16/2007. Discovery Hearing set for 3/26/2007 07:45 AM in Courtroom 1 before Samantha C Sigma. Jury Trial ", "sequence_no": 33, "date_filed": "2007-03-07", "case_id": "13", "sealed": false, "entry_type": "minute"}, 21 | {"text": "set for 4/2/2007 08:30 AM in Courtroom 1 before Samantha C Sigma. (Court Reporter Ness.) (SGP)", "sequence_no": 33, "date_filed": "2007-03-07", "case_id": "13", "sealed": false, "entry_type": "minute"}, 22 | {"text": "ANSWER to Complaint with Jury Demand by Marcia Chaney. (SGP)", "sequence_no": 35, "date_filed": "2007-03-07", "case_id": "13", "sealed": false, "entry_type": "answer"} 23 | ] 24 | }, 25 | { 26 | "title": "Burns vs. Shelley", 27 | "date_filed": "2007-05-03", 28 | "court": "tnmd", 29 | "status": "new", 30 | "docket_entries": [ 31 | {"text": "COMPLAINT against Elizabeth Barrett Browning (Filing fee $ 350.), filed by William Butler Yeats. (Jarvie, Eric)", "sequence_no": 6, "date_filed": "2007-03-05", "case_id": "60", "sealed": false, "entry_type": "cmp"}, 32 | {"text": "Summons Issued as to Robert Burns. (Jarvie, Eric)", "sequence_no": 8, "date_filed": "2007-03-05", "case_id": "60", "sealed": false, "entry_type": "service"}, 33 | {"text": "ANSWER to [1] Complaint with Jury Demand, COUNTERCLAIM against William Butler Yeats by Robert Burns. (Attachments: # (1) Affidavit # (2) Affidavit # (3) Affidavit) (Keats, John)", "sequence_no": 12, "date_filed": "2007-03-05", "case_id": "60", "sealed": false, "entry_type": "answer"}, 34 | {"text": "MOTION to Appoint Expert, MOTION to Continue, MOTION for Disclosure, MOTION for Discovery by Emily Dickinson. Motions referred to Travis F Ten. (Shakespeare, William)", "sequence_no": 19, "date_filed": "2007-03-05", "case_id": "60", "sealed": false, "entry_type": "motion"}, 35 | {"text": "COMPLAINT against Robert Burns ( Filing fee $ 350 receipt number 1111123456.), filed by William Butler Yeats. (Shakespeare, William)", "sequence_no": 26, "date_filed": "2007-03-05", "case_id": "60", "sealed": false, "entry_type": "cmp"}, 36 | {"text": "MOTION to Appoint Expert by William Butler Yeats. Motions referred to Travis F Ten. (Jarvie, Eric)", "sequence_no": 28, "date_filed": "2007-03-05", "case_id": "60", "sealed": false, "entry_type": "motion"}, 37 | {"text": "COMPLAINT against Robert Burns (Filing fee $ 350.), filed by William Butler Yeats. (Jarvie, Eric)", "sequence_no": 30, "date_filed": "2007-03-05", "case_id": "60", "sealed": false, "entry_type": "cmp"}, 38 | {"text": "Seal SetDocumentAccess(256) by William Butler Yeats. Motions referred to Travis F Ten. (Shakespeare, William)", "sequence_no": 32, "date_filed": "2007-03-07", "case_id": "60", "sealed": false, "entry_type": "motion"}, 39 | {"text": "MOTION to Appoint Expert by William Butler Yeats. Motions referred to Travis F Ten. (Jarvie, Eric)", "sequence_no": 34, "date_filed": "2007-03-13", "case_id": "60", "sealed": false, "entry_type": "motion"}, 40 | {"text": "MOTION for Bill of Costs by Robert Burns. Motions referred to Travis F Ten. (Attachments: # (1) Appendix Attachment 1)", "sequence_no": 36, "date_filed": "2007-04-16", "case_id": "60", "sealed": false, "entry_type": "motion"}, 41 | {"text": "MOTION for Bill of Costs by Robert Burns. Motions referred to Travis F Ten.", "sequence_no": 38, "date_filed": "2007-04-16", "case_id": "60", "sealed": false, "entry_type": "motion"}, 42 | {"text": "MOTION for Bill of Costs by Robert Burns. Motions referred to Travis F Ten.", "sequence_no": 40, "date_filed": "2007-04-17", "case_id": "60", "sealed": false, "entry_type": "motion"}, 43 | {"text": "MOTION for Bill of Costs by Robert Burns. Motions referred to Travis F Ten.", "sequence_no": 42, "date_filed": "2007-04-17", "case_id": "60", "sealed": false, "entry_type": "motion"}, 44 | {"text": "MOTION for Bill of Costs by Robert Burns. Motions referred to Travis F Ten.", "sequence_no": 44, "date_filed": "2007-04-17", "case_id": "60", "sealed": false, "entry_type": "motion"}, 45 | {"text": "MOTION to Appoint Expert. Motions referred to case referral judge. (Jarvie, Eric)", "sequence_no": 46, "date_filed": "2007-04-26", "case_id": "60", "sealed": false, "entry_type": "motion"}, 46 | {"text": "ORDER granting [6] Motion to Appoint Expert; granting [10] Motion for Bill of Costs (Jarvie, Eric)", "sequence_no": 48, "date_filed": "2007-04-26", "case_id": "60", "sealed": false, "entry_type": "order"}, 47 | {"text": " MOTION to Appoint Expert by Ted Matthews, Mike Krzyzewski, George Mason, John Adams, Raplh King, Joseph Allen, Bradford W McClanahan, Richard Manuel, Yung Mann, Wilhelm Leibnitz, Larry W Little, James Madison. Motions referred to case referral judge", "sequence_no": 51, "date_filed": "2007-05-03", "case_id": "60", "sealed": false, "entry_type": "motion"}, 48 | {"text": ". (SP)", "sequence_no": 51, "date_filed": "2007-05-03", "case_id": "60", "sealed": false, "entry_type": "motion"}, 49 | {"text": "MOTION for Bond by Emily Dickinson. Motions referred to Travis F Ten.", "sequence_no": 53, "date_filed": "2007-05-14", "case_id": "60", "sealed": false, "entry_type": "motion"}, 50 | {"text": "MOTION for Leave to Appear, MOTION to Appoint Expert, MOTION for Disclosure, MOTION for Discovery, MOTION to Dismiss ( Responses due by 5/30/2007) by William Butler Yeats. Motions referred to case referral judge. (Jarvie, EAJ)", "sequence_no": 56, "date_filed": "2007-05-29", "case_id": "60", "sealed": false, "entry_type": "motion"}, 51 | {"text": "MOTION to Appoint Expert by William Blake. Motions referred to case referral judge. (Jarvie, Eric)", "sequence_no": 62, "date_filed": "2008-03-31", "case_id": "60", "sealed": false, "entry_type": "motion"}, 52 | {"text": "MOTION for Leave to Appear, MOTION to Appoint Expert, MOTION for Disclosure, MOTION for Discovery, MOTION to Dismiss ( Responses due by 4/13/2008) by Elizabeth Barrett Browning. Motions referred to case referral judge. (Jarvie, Eric)", "sequence_no": 64, "date_filed": "2008-03-31", "case_id": "60", "sealed": false, "entry_type": "motion"}, 53 | {"text": "MOTION to Appoint Expert by Emily Dickinson. Motions referred to Travis F Ten. (Jarvie, Eric)", "sequence_no": 70, "date_filed": "2008-03-31", "case_id": "60", "sealed": false, "entry_type": "motion"}, 54 | {"text": "MOTION to Appoint Custodian, MOTION to Appoint Expert by William Blake. Motions referred to case referral judge. (Jarvie, Eric)", "sequence_no": 72, "date_filed": "2008-03-31", "case_id": "60", "sealed": false, "entry_type": "motion"}, 55 | {"text": "MOTION to Appoint Expert, MOTION for Certificate of Appealability, MOTION to Change Venue, MOTION to Compel, MOTION to Consolidate Cases, MOTION to Continue, MOTION for Declaration of Mistrial, MOTION for Declaratory Judgment ( Responses due by 4/14", "sequence_no": 75, "date_filed": "2008-04-01", "case_id": "60", "sealed": false, "entry_type": "motion"}, 56 | {"text": "/2008), MOTION for Bill of Costs, MOTION to Bifurcate, MOTION for Attorney Fees by William Blake. Motions referred to case referral judge. (Jarvie, Eric)", "sequence_no": 75, "date_filed": "2008-04-01", "case_id": "60", "sealed": false, "entry_type": "motion"}, 57 | {"text": "MOTION to Appoint Expert, MOTION to Approve Consent Judgment, MOTION for Disclosure, MOTION for Discovery, MOTION in Limine, MOTION to Intervene by William Blake. Motions referred to case referral judge. (Jarvie, Eric)", "sequence_no": 87, "date_filed": "2008-04-01", "case_id": "60", "sealed": false, "entry_type": "motion"}, 58 | {"text": "MOTION to Appoint Expert by William Blake. Motions referred to case referral judge. (Jarvie, Eric)", "sequence_no": 94, "date_filed": "2008-04-14", "case_id": "60", "sealed": false, "entry_type": "motion"}, 59 | {"text": "MOTION to Appoint Expert by Robert Burns. Motions referred to Travis F Ten. (Jarvie, Eric)", "sequence_no": 96, "date_filed": "2008-05-09", "case_id": "60", "sealed": false, "entry_type": "motion"}, 60 | {"text": "TRANSCRIPT of Proceedings (Jarvie, Eric)", "sequence_no": 98, "date_filed": "2008-06-02", "case_id": "60", "sealed": false, "entry_type": "oth_evt"}, 61 | {"text": "Minute Entry for proceedings held before Albert B Alpha: Filip B Fifteen and Travis F Ten no longer assigned to case.. (MacAdie, Patrick)", "sequence_no": 100, "date_filed": "2008-08-05", "case_id": "60", "sealed": false, "entry_type": "minute"} 62 | ] 63 | } 64 | ] 65 | -------------------------------------------------------------------------------- /app/core/courts.py: -------------------------------------------------------------------------------- 1 | courts = { 2 | 'akb': {'type': 'bankruptcy', 3 | 'full_name': 'United States Bankruptcy Court, D. Alaska', 4 | 'parent': None, 5 | 'short_name': 'D. Alaska'}, 6 | 'akd': {'type': 'district', 7 | 'full_name': 'District Court, D. Alaska', 8 | 'parent': 'ca9', 9 | 'short_name': 'D. Alaska'}, 10 | 'almb': {'type': 'bankruptcy', 11 | 'full_name': 'United States Bankruptcy Court, M.D. Alabama', 12 | 'parent': 'ca11', 13 | 'short_name': 'M.D. Alabama'}, 14 | 'almd': {'type': 'district', 15 | 'full_name': 'District Court, M.D. Alabama', 16 | 'parent': 'ca11', 17 | 'short_name': 'M.D. Alabama'}, 18 | 'alnb': {'type': 'bankruptcy', 19 | 'full_name': 'United States Bankruptcy Court, N.D. Alabama', 20 | 'parent': None, 21 | 'short_name': 'N.D. Alabama'}, 22 | 'alnd': {'type': 'district', 23 | 'full_name': 'District Court, N.D. Alabama', 24 | 'parent': 'ca11', 25 | 'short_name': 'N.D. Alabama'}, 26 | 'alsb': {'type': 'bankruptcy', 27 | 'full_name': 'United States Bankruptcy Court, S.D. Alabama', 28 | 'parent': None, 29 | 'short_name': 'S.D. Alabama'}, 30 | 'alsd': {'type': 'district', 31 | 'full_name': 'District Court, S.D. Alabama', 32 | 'parent': 'ca11', 33 | 'short_name': 'S.D. Alabama'}, 34 | 'arb': {'type': 'bankruptcy', 35 | 'full_name': 'United States Bankruptcy Court, D. Arizona', 36 | 'parent': None, 37 | 'short_name': 'D. Arizona'}, 38 | 'areb': {'type': 'bankruptcy', 39 | 'full_name': 'United States Bankruptcy Court, E.D. Arkansas', 40 | 'parent': None, 41 | 'short_name': 'E.D. Arkansas'}, 42 | 'ared': {'type': 'district', 43 | 'full_name': 'District Court, E.D. Arkansas', 44 | 'parent': 'ca8', 45 | 'short_name': 'E.D. Arkansas'}, 46 | 'arwb': {'type': 'bankruptcy', 47 | 'full_name': 'United States Bankruptcy Court, W.D. Arkansas', 48 | 'parent': None, 49 | 'short_name': 'W.D. Arkansas'}, 50 | 'arwd': {'type': 'district', 51 | 'full_name': 'District Court, W.D. Arkansas', 52 | 'parent': 'ca8', 53 | 'short_name': 'W.D. Arkansas'}, 54 | 'azd': {'type': 'district', 55 | 'full_name': 'District Court, D. Arizona', 56 | 'parent': 'ca9', 57 | 'short_name': 'D. Arizona'}, 58 | 'ca1': {'type': 'appellate', 59 | 'full_name': 'Court of Appeals for the First Circuit', 60 | 'parent': 'scotus', 61 | 'short_name': 'First Circuit'}, 62 | 'ca10': {'type': 'appellate', 63 | 'full_name': 'Court of Appeals for the Tenth Circuit', 64 | 'parent': 'scotus', 65 | 'short_name': 'Tenth Circuit'}, 66 | 'ca11': {'type': 'appellate', 67 | 'full_name': 'Court of Appeals for the Eleventh Circuit', 68 | 'parent': 'scotus', 69 | 'short_name': 'Eleventh Circuit'}, 70 | 'ca2': {'type': 'appellate', 71 | 'full_name': 'Court of Appeals for the Second Circuit', 72 | 'parent': 'scotus', 73 | 'short_name': 'Second Circuit'}, 74 | 'ca3': {'type': 'appellate', 75 | 'full_name': 'Court of Appeals for the Third Circuit', 76 | 'parent': 'scotus', 77 | 'short_name': 'Third Circuit'}, 78 | 'ca4': {'type': 'appellate', 79 | 'full_name': 'Court of Appeals for the Fourth Circuit', 80 | 'parent': 'scotus', 81 | 'short_name': 'Fourth Circuit'}, 82 | 'ca5': {'type': 'appellate', 83 | 'full_name': 'Court of Appeals for the Fifth Circuit', 84 | 'parent': 'scotus', 85 | 'short_name': 'Fifth Circuit'}, 86 | 'ca6': {'type': 'appellate', 87 | 'full_name': 'Court of Appeals for the Sixth Circuit', 88 | 'parent': 'scotus', 89 | 'short_name': 'Sixth Circuit'}, 90 | 'ca7': {'type': 'appellate', 91 | 'full_name': 'Court of Appeals for the Seventh Circuit', 92 | 'parent': 'scotus', 93 | 'short_name': 'Seventh Circuit'}, 94 | 'ca8': {'type': 'appellate', 95 | 'full_name': 'Court of Appeals for the Eighth Circuit', 96 | 'parent': 'scotus', 97 | 'short_name': 'Eighth Circuit'}, 98 | 'ca9': {'type': 'appellate', 99 | 'full_name': 'Court of Appeals for the Ninth Circuit', 100 | 'parent': 'scotus', 101 | 'short_name': 'Ninth Circuit'}, 102 | 'cacb': {'type': 'bankruptcy', 103 | 'full_name': 'United States Bankruptcy Court, C.D. California', 104 | 'parent': None, 105 | 'short_name': 'C.D. California'}, 106 | 'cacd': {'type': 'district', 107 | 'full_name': 'District Court, C.D. California', 108 | 'parent': 'ca9', 109 | 'short_name': 'C.D. California'}, 110 | 'cadc': {'type': 'appellate', 111 | 'full_name': 'Court of Appeals for the D.C. Circuit', 112 | 'parent': 'scotus', 113 | 'short_name': 'D.C. Circuit'}, 114 | 'caeb': {'type': 'bankruptcy', 115 | 'full_name': 'United States Bankruptcy Court, E.D. California', 116 | 'parent': None, 117 | 'short_name': 'E.D. California'}, 118 | 'caed': {'type': 'district', 119 | 'full_name': 'District Court, E.D. California', 120 | 'parent': 'ca9', 121 | 'short_name': 'E.D. California'}, 122 | 'cafc': {'type': 'appellate', 123 | 'full_name': 'Court of Appeals for the Federal Circuit', 124 | 'parent': 'scotus', 125 | 'short_name': 'Federal Circuit'}, 126 | 'canb': {'type': 'bankruptcy', 127 | 'full_name': 'United States Bankruptcy Court, N.D. California', 128 | 'parent': None, 129 | 'short_name': 'N.D. California'}, 130 | 'cand': {'type': 'district', 131 | 'full_name': 'District Court, N.D. California', 132 | 'parent': 'ca9', 133 | 'short_name': 'N.D. California'}, 134 | 'casb': {'type': 'bankruptcy', 135 | 'full_name': 'United States Bankruptcy Court, S.D. California', 136 | 'parent': None, 137 | 'short_name': 'S.D. California'}, 138 | 'casd': {'type': 'district', 139 | 'full_name': 'District Court, S.D. California', 140 | 'parent': 'ca9', 141 | 'short_name': 'S.D. California'}, 142 | 'cob': {'type': 'bankruptcy', 143 | 'full_name': 'United States Bankruptcy Court, D. Colorado', 144 | 'parent': None, 145 | 'short_name': 'D. Colorado'}, 146 | 'cod': {'type': 'district', 147 | 'full_name': 'District Court, D. Colorado', 148 | 'parent': 'ca10', 149 | 'short_name': 'D. Colorado'}, 150 | 'ctb': {'type': 'bankruptcy', 151 | 'full_name': 'United States Bankruptcy Court, D. Connecticut', 152 | 'parent': None, 153 | 'short_name': 'D. Connecticut'}, 154 | 'ctd': {'type': 'district', 155 | 'full_name': 'District Court, D. Connecticut', 156 | 'parent': 'ca2', 157 | 'short_name': 'D. Connecticut'}, 158 | 'dcb': {'type': 'bankruptcy', 159 | 'full_name': 'United States Bankruptcy Court, District of Columbia', 160 | 'parent': None, 161 | 'short_name': 'District of Columbia'}, 162 | 'dcd': {'type': 'district', 163 | 'full_name': 'District Court, District of Columbia', 164 | 'parent': 'cadc', 165 | 'short_name': 'District of Columbia'}, 166 | 'deb': {'type': 'bankruptcy', 167 | 'full_name': 'United States Bankruptcy Court, D. Delaware', 168 | 'parent': None, 169 | 'short_name': 'D. Delaware'}, 170 | 'ded': {'type': 'district', 171 | 'full_name': 'District Court, D. Delaware', 172 | 'parent': 'ca3', 173 | 'short_name': 'D. Delaware'}, 174 | 'flmb': {'type': 'bankruptcy', 175 | 'full_name': 'United States Bankruptcy Court, M.D. Florida', 176 | 'parent': None, 177 | 'short_name': 'M.D. Florida'}, 178 | 'flmd': {'type': 'district', 179 | 'full_name': 'District Court, M.D. Florida', 180 | 'parent': 'ca11', 181 | 'short_name': 'M.D. Florida'}, 182 | 'flnb': {'type': 'bankruptcy', 183 | 'full_name': 'United States Bankruptcy Court, N.D. Florida', 184 | 'parent': None, 185 | 'short_name': 'N.D. Florida'}, 186 | 'flnd': {'type': 'district', 187 | 'full_name': 'District Court, N.D. Florida', 188 | 'parent': 'ca11', 189 | 'short_name': 'N.D. Florida'}, 190 | 'flsb': {'type': 'bankruptcy', 191 | 'full_name': 'United States Bankruptcy Court, S.D. Florida.', 192 | 'parent': None, 193 | 'short_name': 'S.D. Florida'}, 194 | 'flsd': {'type': 'district', 195 | 'full_name': 'District Court, S.D. Florida', 196 | 'parent': 'ca11', 197 | 'short_name': 'S.D. Florida'}, 198 | 'gamb': {'type': 'bankruptcy', 199 | 'full_name': 'United States Bankruptcy Court, M.D. Georgia', 200 | 'parent': None, 201 | 'short_name': 'M.D. Georgia'}, 202 | 'gamd': {'type': 'district', 203 | 'full_name': 'District Court, M.D. Georgia', 204 | 'parent': 'ca11', 205 | 'short_name': 'M.D. Georgia'}, 206 | 'ganb': {'type': 'bankruptcy', 207 | 'full_name': 'United States Bankruptcy Court, N.D. Georgia', 208 | 'parent': None, 209 | 'short_name': 'N.D. Georgia'}, 210 | 'gand': {'type': 'district', 211 | 'full_name': 'District Court, N.D. Georgia', 212 | 'parent': 'ca11', 213 | 'short_name': 'N.D. Georgia'}, 214 | 'gasb': {'type': 'bankruptcy', 215 | 'full_name': 'United States Bankruptcy Court, S.D. Georgia', 216 | 'parent': None, 217 | 'short_name': 'S.D. Georgia'}, 218 | 'gasd': {'type': 'district', 219 | 'full_name': 'District Court, S.D. Georgia', 220 | 'parent': 'ca11', 221 | 'short_name': 'S.D. Georgia'}, 222 | 'gub': {'type': 'bankruptcy', 223 | 'full_name': 'United States Bankruptcy Court, D. Guam', 224 | 'parent': None, 225 | 'short_name': 'D. Guam'}, 226 | 'gud': {'type': 'district', 227 | 'full_name': 'District Court, D. Guam', 228 | 'parent': 'ca9', 229 | 'short_name': 'D. Guam'}, 230 | 'hib': {'type': 'bankruptcy', 231 | 'full_name': 'United States Bankruptcy Court, D. Hawaii', 232 | 'parent': None, 233 | 'short_name': 'D. Hawaii'}, 234 | 'hid': {'type': 'district', 235 | 'full_name': 'District Court, D. Hawaii', 236 | 'parent': 'ca9', 237 | 'short_name': 'D. Hawaii'}, 238 | 'ianb': {'type': 'bankruptcy', 239 | 'full_name': 'United States Bankruptcy Court, N.D. Iowa', 240 | 'parent': None, 241 | 'short_name': 'N.D. Iowa'}, 242 | 'iand': {'type': 'district', 243 | 'full_name': 'District Court, N.D. Iowa', 244 | 'parent': 'ca8', 245 | 'short_name': 'N.D. Iowa'}, 246 | 'iasb': {'type': 'bankruptcy', 247 | 'full_name': 'United States Bankruptcy Court, S.D. Iowa', 248 | 'parent': None, 249 | 'short_name': 'S.D. Iowa'}, 250 | 'iasd': {'type': 'district', 251 | 'full_name': 'District Court, S.D. Iowa', 252 | 'parent': 'ca8', 253 | 'short_name': 'S.D. Iowa'}, 254 | 'idb': {'type': 'bankruptcy', 255 | 'full_name': 'United States Bankruptcy Court, D. Idaho', 256 | 'parent': None, 257 | 'short_name': 'D. Idaho'}, 258 | 'idd': {'type': 'district', 259 | 'full_name': 'District Court, D. Idaho', 260 | 'parent': 'ca9', 261 | 'short_name': 'D. Idaho'}, 262 | 'ilcb': {'type': 'bankruptcy', 263 | 'full_name': 'United States Bankruptcy Court, C.D. Illinois', 264 | 'parent': None, 265 | 'short_name': 'C.D. Illinois'}, 266 | 'ilcd': {'type': 'district', 267 | 'full_name': 'District Court, C.D. Illinois', 268 | 'parent': 'ca7', 269 | 'short_name': 'C.D. Illinois'}, 270 | 'ilnb': {'type': 'bankruptcy', 271 | 'full_name': 'United States Bankruptcy Court, N.D. Illinois', 272 | 'parent': None, 273 | 'short_name': 'N.D. Illinois'}, 274 | 'ilnd': {'type': 'district', 275 | 'full_name': 'District Court, N.D. Illinois', 276 | 'parent': 'ca7', 277 | 'short_name': 'N.D. Illinois'}, 278 | 'ilsb': {'type': 'bankruptcy', 279 | 'full_name': 'United States Bankruptcy Court, S.D. Illinois', 280 | 'parent': None, 281 | 'short_name': 'S.D. Illinois'}, 282 | 'ilsd': {'type': 'district', 283 | 'full_name': 'District Court, S.D. Illinois', 284 | 'parent': 'ca7', 285 | 'short_name': 'S.D. Illinois'}, 286 | 'innb': {'type': 'bankruptcy', 287 | 'full_name': 'United States Bankruptcy Court, N.D. Indiana', 288 | 'parent': None, 289 | 'short_name': 'N.D. Indiana'}, 290 | 'innd': {'type': 'district', 291 | 'full_name': 'District Court, N.D. Indiana', 292 | 'parent': 'ca7', 293 | 'short_name': 'N.D. Indiana'}, 294 | 'insb': {'type': 'bankruptcy', 295 | 'full_name': 'United States Bankruptcy Court, S.D. Indiana', 296 | 'parent': None, 297 | 'short_name': 'S.D. Indiana'}, 298 | 'insd': {'type': 'district', 299 | 'full_name': 'District Court, S.D. Indiana', 300 | 'parent': 'ca7', 301 | 'short_name': 'S.D. Indiana'}, 302 | 'ksb': {'type': 'bankruptcy', 303 | 'full_name': 'United States Bankruptcy Court, D. Kansas', 304 | 'parent': None, 305 | 'short_name': 'D. Kansas'}, 306 | 'ksd': {'type': 'district', 307 | 'full_name': 'District Court, D. Kansas', 308 | 'parent': 'ca10', 309 | 'short_name': 'D. Kansas'}, 310 | 'kyeb': {'type': 'bankruptcy', 311 | 'full_name': 'United States Bankruptcy Court, E.D. Kentucky', 312 | 'parent': None, 313 | 'short_name': 'E.D. Kentucky'}, 314 | 'kyed': {'type': 'district', 315 | 'full_name': 'District Court, E.D. Kentucky', 316 | 'parent': 'ca6', 317 | 'short_name': 'E.D. Kentucky'}, 318 | 'kywb': {'type': 'bankruptcy', 319 | 'full_name': 'United States Bankruptcy Court, W.D. Kentucky', 320 | 'parent': None, 321 | 'short_name': 'W.D. Kentucky'}, 322 | 'kywd': {'type': 'district', 323 | 'full_name': 'District Court, W.D. Kentucky', 324 | 'parent': 'ca6', 325 | 'short_name': 'W.D. Kentucky'}, 326 | 'laeb': {'type': 'bankruptcy', 327 | 'full_name': 'United States Bankruptcy Court, E.D. Louisiana', 328 | 'parent': None, 329 | 'short_name': 'E.D. Louisiana'}, 330 | 'laed': {'type': 'district', 331 | 'full_name': 'District Court, E.D. Louisiana', 332 | 'parent': 'ca5', 333 | 'short_name': 'E.D. Louisiana'}, 334 | 'lamb': {'type': 'bankruptcy', 335 | 'full_name': 'United States Bankruptcy Court, M.D. Louisiana', 336 | 'parent': None, 337 | 'short_name': 'M.D. Louisiana'}, 338 | 'lamd': {'type': 'district', 339 | 'full_name': 'District Court, M.D. Louisiana', 340 | 'parent': 'ca5', 341 | 'short_name': 'M.D. Louisiana'}, 342 | 'lawb': {'type': 'bankruptcy', 343 | 'full_name': 'United States Bankruptcy Court, W.D. Louisiana', 344 | 'parent': None, 345 | 'short_name': 'W.D. Louisiana'}, 346 | 'lawd': {'type': 'district', 347 | 'full_name': 'District Court, W.D. Louisiana', 348 | 'parent': 'ca5', 349 | 'short_name': 'W.D. Louisiana'}, 350 | 'mab': {'type': 'bankruptcy', 351 | 'full_name': 'United States Bankruptcy Court, D. Massachusetts', 352 | 'parent': None, 353 | 'short_name': 'D. Massachusetts'}, 354 | 'mad': {'type': 'district', 355 | 'full_name': 'District Court, D. Massachusetts', 356 | 'parent': 'ca1', 357 | 'short_name': 'D. Massachusetts'}, 358 | 'mdb': {'type': 'bankruptcy', 359 | 'full_name': 'United States Bankruptcy Court, D. Maryland', 360 | 'parent': None, 361 | 'short_name': 'D. Maryland'}, 362 | 'mdd': {'type': 'district', 363 | 'full_name': 'District Court, D. Maryland', 364 | 'parent': 'ca4', 365 | 'short_name': 'D. Maryland'}, 366 | 'meb': {'type': 'bankruptcy', 367 | 'full_name': 'United States Bankruptcy Court, D. Maine', 368 | 'parent': None, 369 | 'short_name': 'D. Maine'}, 370 | 'med': {'type': 'district', 371 | 'full_name': 'District Court, D. Maine', 372 | 'parent': 'ca1', 373 | 'short_name': 'D. Maine'}, 374 | 'mieb': {'type': 'bankruptcy', 375 | 'full_name': 'United States Bankruptcy Court, E.D. Michigan', 376 | 'parent': None, 377 | 'short_name': 'E.D. Michigan'}, 378 | 'mied': {'type': 'district', 379 | 'full_name': 'District Court, E.D. Michigan', 380 | 'parent': 'ca6', 381 | 'short_name': 'E.D. Michigan'}, 382 | 'miwb': {'type': 'bankruptcy', 383 | 'full_name': 'United States Bankruptcy Court, W.D. Michigan', 384 | 'parent': None, 385 | 'short_name': 'W.D. Michigan'}, 386 | 'miwd': {'type': 'district', 387 | 'full_name': 'District Court, W.D. Michigan', 388 | 'parent': 'ca6', 389 | 'short_name': 'W.D. Michigan'}, 390 | 'mnb': {'type': 'bankruptcy', 391 | 'full_name': 'United States Bankruptcy Court, D. Minnesota', 392 | 'parent': None, 393 | 'short_name': 'D. Minnesota'}, 394 | 'mnd': {'type': 'district', 395 | 'full_name': 'District Court, D. Minnesota', 396 | 'parent': 'ca8', 397 | 'short_name': 'D. Minnesota'}, 398 | 'moeb': {'type': 'bankruptcy', 399 | 'full_name': 'United States Bankruptcy Court, E.D. Missouri', 400 | 'parent': None, 401 | 'short_name': 'E.D. Missouri'}, 402 | 'moed': {'type': 'district', 403 | 'full_name': 'District Court, E.D. Missouri', 404 | 'parent': 'ca8', 405 | 'short_name': 'E.D. Missouri'}, 406 | 'mowb': {'type': 'bankruptcy', 407 | 'full_name': 'United States Bankruptcy Court, W.D. Missouri', 408 | 'parent': None, 409 | 'short_name': 'W.D. Missouri'}, 410 | 'mowd': {'type': 'district', 411 | 'full_name': 'District Court, W.D. Missouri', 412 | 'parent': 'ca8', 413 | 'short_name': 'W.D. Missouri'}, 414 | 'msnb': {'type': 'bankruptcy', 415 | 'full_name': 'United States Bankruptcy Court, N.D. Mississippi', 416 | 'parent': None, 417 | 'short_name': 'N.D. Mississippi'}, 418 | 'msnd': {'type': 'district', 419 | 'full_name': 'District Court, N.D. Mississippi', 420 | 'parent': 'ca5', 421 | 'short_name': 'N.D. Mississippi'}, 422 | 'mssb': {'type': 'bankruptcy', 423 | 'full_name': 'United States Bankruptcy Court, S.D. Mississippi', 424 | 'parent': None, 425 | 'short_name': 'S.D. Mississippi'}, 426 | 'mssd': {'type': 'district', 427 | 'full_name': 'District Court, S.D. Mississippi', 428 | 'parent': 'ca5', 429 | 'short_name': 'S.D. Mississippi'}, 430 | 'mtb': {'type': 'bankruptcy', 431 | 'full_name': 'United States Bankruptcy Court, D. Montana', 432 | 'parent': None, 433 | 'short_name': 'D. Montana'}, 434 | 'mtd': {'type': 'district', 435 | 'full_name': 'District Court, D. Montana', 436 | 'parent': 'ca9', 437 | 'short_name': 'D. Montana'}, 438 | 'nceb': {'type': 'bankruptcy', 439 | 'full_name': 'United States Bankruptcy Court, E.D. North Carolina', 440 | 'parent': None, 441 | 'short_name': 'E.D. North Carolina'}, 442 | 'nced': {'type': 'district', 443 | 'full_name': 'District Court, E.D. North Carolina', 444 | 'parent': 'ca4', 445 | 'short_name': 'E.D. North Carolina'}, 446 | 'ncmb': {'type': 'bankruptcy', 447 | 'full_name': 'United States Bankruptcy Court, M.D. North Carolina', 448 | 'parent': None, 449 | 'short_name': 'M.D. North Carolina'}, 450 | 'ncmd': {'type': 'district', 451 | 'full_name': 'District Court, M.D. North Carolina', 452 | 'parent': 'ca4', 453 | 'short_name': 'M.D. North Carolina'}, 454 | 'ncwb': {'type': 'bankruptcy', 455 | 'full_name': 'United States Bankruptcy Court, W.D. North Carolina', 456 | 'parent': None, 457 | 'short_name': 'W.D. North Carolina'}, 458 | 'ncwd': {'type': 'district', 459 | 'full_name': 'District Court, W.D. North Carolina', 460 | 'parent': 'ca4', 461 | 'short_name': 'W.D. North Carolina'}, 462 | 'ndb': {'type': 'bankruptcy', 463 | 'full_name': 'United States Bankruptcy Court, D. North Dakota', 464 | 'parent': None, 465 | 'short_name': 'D. North Dakota'}, 466 | 'ndd': {'type': 'district', 467 | 'full_name': 'District Court, D. North Dakota', 468 | 'parent': 'ca8', 469 | 'short_name': 'D. North Dakota'}, 470 | 'nebraskab': {'type': 'bankruptcy', 471 | 'full_name': 'United States Bankruptcy Court, D. Nebraska', 472 | 'parent': None, 473 | 'short_name': 'D. Nebraska'}, 474 | 'ned': {'type': 'district', 475 | 'full_name': 'District Court, D. Nebraska', 476 | 'parent': 'ca8', 477 | 'short_name': 'D. Nebraska'}, 478 | 'nhb': {'type': 'bankruptcy', 479 | 'full_name': 'United States Bankruptcy Court, D. New Hampshire', 480 | 'parent': None, 481 | 'short_name': 'D. New Hampshire'}, 482 | 'nhd': {'type': 'district', 483 | 'full_name': 'District Court, D. New Hampshire', 484 | 'parent': 'ca1', 485 | 'short_name': 'D. New Hampshire'}, 486 | 'njb': {'type': 'bankruptcy', 487 | 'full_name': 'United States Bankruptcy Court, D. New Jersey', 488 | 'parent': None, 489 | 'short_name': 'D. New Jersey'}, 490 | 'njd': {'type': 'district', 491 | 'full_name': 'District Court, D. New Jersey', 492 | 'parent': 'ca3', 493 | 'short_name': 'D. New Jersey'}, 494 | 'nmb': {'type': 'bankruptcy', 495 | 'full_name': 'United States Bankruptcy Court, D. New Mexico', 496 | 'parent': None, 497 | 'short_name': 'D. New Mexico'}, 498 | 'nmd': {'type': 'district', 499 | 'full_name': 'District Court, D. New Mexico', 500 | 'parent': 'ca10', 501 | 'short_name': 'D. New Mexico'}, 502 | 'nmib': {'type': 'bankruptcy', 503 | 'full_name': 'United States Bankruptcy Court, Northern Mariana ' 504 | 'Islands', 505 | 'parent': None, 506 | 'short_name': 'Northern Mariana Islands'}, 507 | 'nmid': {'type': 'district', 508 | 'full_name': 'District Court, Northern Mariana Islands', 509 | 'parent': 'ca9', 510 | 'short_name': 'Northern Mariana Islands'}, 511 | 'nvb': {'type': 'bankruptcy', 512 | 'full_name': 'United States Bankruptcy Court, D. Nevada', 513 | 'parent': None, 514 | 'short_name': 'D. Nevada'}, 515 | 'nvd': {'type': 'district', 516 | 'full_name': 'District Court, D. Nevada', 517 | 'parent': 'ca9', 518 | 'short_name': 'D. Nevada'}, 519 | 'nyeb': {'type': 'bankruptcy', 520 | 'full_name': 'United States Bankruptcy Court, E.D. New York', 521 | 'parent': None, 522 | 'short_name': 'E.D. New York'}, 523 | 'nyed': {'type': 'district', 524 | 'full_name': 'District Court, E.D. New York', 525 | 'parent': 'ca2', 526 | 'short_name': 'E.D. New York'}, 527 | 'nynb': {'type': 'bankruptcy', 528 | 'full_name': 'United States Bankruptcy Court, N.D. New York', 529 | 'parent': None, 530 | 'short_name': 'N.D. New York'}, 531 | 'nynd': {'type': 'district', 532 | 'full_name': 'District Court, N.D. New York', 533 | 'parent': 'ca2', 534 | 'short_name': 'N.D. New York'}, 535 | 'nysb': {'type': 'bankruptcy', 536 | 'full_name': 'United States Bankruptcy Court, S.D. New York', 537 | 'parent': None, 538 | 'short_name': 'S.D. New York'}, 539 | 'nysd': {'type': 'district', 540 | 'full_name': 'District Court, S.D. New York', 541 | 'parent': 'ca2', 542 | 'short_name': 'S.D. New York'}, 543 | 'nywb': {'type': 'bankruptcy', 544 | 'full_name': 'United States Bankruptcy Court, W.D. New York', 545 | 'parent': None, 546 | 'short_name': 'W.D. New York'}, 547 | 'nywd': {'type': 'district', 548 | 'full_name': 'District Court, W.D. New York', 549 | 'parent': 'ca2', 550 | 'short_name': 'W.D. New York'}, 551 | 'ohnb': {'type': 'bankruptcy', 552 | 'full_name': 'United States Bankruptcy Court, N.D. Ohio', 553 | 'parent': None, 554 | 'short_name': 'N.D. Ohio'}, 555 | 'ohnd': {'type': 'district', 556 | 'full_name': 'District Court, N.D. Ohio', 557 | 'parent': 'ca6', 558 | 'short_name': 'N.D. Ohio'}, 559 | 'ohsb': {'type': 'bankruptcy', 560 | 'full_name': 'United States Bankruptcy Court, S.D. Ohio', 561 | 'parent': None, 562 | 'short_name': 'S.D. Ohio'}, 563 | 'ohsd': {'type': 'district', 564 | 'full_name': 'District Court, S.D. Ohio', 565 | 'parent': 'ca6', 566 | 'short_name': 'S.D. Ohio'}, 567 | 'okeb': {'type': 'bankruptcy', 568 | 'full_name': 'United States Bankruptcy Court, E.D. Oklahoma', 569 | 'parent': None, 570 | 'short_name': 'E.D. Oklahoma'}, 571 | 'oked': {'type': 'district', 572 | 'full_name': 'District Court, E.D. Oklahoma', 573 | 'parent': 'ca10', 574 | 'short_name': 'E.D. Oklahoma'}, 575 | 'oknb': {'type': 'bankruptcy', 576 | 'full_name': 'United States Bankruptcy Court, N.D. Oklahoma', 577 | 'parent': None, 578 | 'short_name': 'N.D. Oklahoma'}, 579 | 'oknd': {'type': 'district', 580 | 'full_name': 'District Court, N.D. Oklahoma', 581 | 'parent': 'ca10', 582 | 'short_name': 'N.D. Oklahoma'}, 583 | 'okwb': {'type': 'bankruptcy', 584 | 'full_name': 'United States Bankruptcy Court, W.D. Oklahoma', 585 | 'parent': None, 586 | 'short_name': 'W.D. Oklahoma'}, 587 | 'okwd': {'type': 'district', 588 | 'full_name': 'District Court, W.D. Oklahoma', 589 | 'parent': 'ca10', 590 | 'short_name': 'W.D. Oklahoma'}, 591 | 'orb': {'type': 'bankruptcy', 592 | 'full_name': 'United States Bankruptcy Court, D. Oregon', 593 | 'parent': None, 594 | 'short_name': 'D. Oregon'}, 595 | 'ord': {'type': 'district', 596 | 'full_name': 'District Court, D. Oregon', 597 | 'parent': 'ca9', 598 | 'short_name': 'D. Oregon'}, 599 | 'paeb': {'type': 'bankruptcy', 600 | 'full_name': 'United States Bankruptcy Court, E.D. Pennsylvania', 601 | 'parent': None, 602 | 'short_name': 'E.D. Pennsylvania'}, 603 | 'paed': {'type': 'district', 604 | 'full_name': 'District Court, E.D. Pennsylvania', 605 | 'parent': 'ca3', 606 | 'short_name': 'E.D. Pennsylvania'}, 607 | 'pamb': {'type': 'bankruptcy', 608 | 'full_name': 'United States Bankruptcy Court, M.D. Pennsylvania', 609 | 'parent': None, 610 | 'short_name': 'M.D. Pennsylvania'}, 611 | 'pamd': {'type': 'district', 612 | 'full_name': 'District Court, M.D. Pennsylvania', 613 | 'parent': 'ca3', 614 | 'short_name': 'M.D. Pennsylvania'}, 615 | 'pawb': {'type': 'bankruptcy', 616 | 'full_name': 'United States Bankruptcy Court, W.D. Pennsylvania', 617 | 'parent': None, 618 | 'short_name': 'W.D. Pennsylvania'}, 619 | 'pawd': {'type': 'district', 620 | 'full_name': 'District Court, W.D. Pennsylvania', 621 | 'parent': 'ca3', 622 | 'short_name': 'W.D. Pennsylvania'}, 623 | 'prb': {'type': 'bankruptcy', 624 | 'full_name': 'United States Bankruptcy Court, D. Puerto Rico', 625 | 'parent': None, 626 | 'short_name': 'D. Puerto Rico'}, 627 | 'prd': {'type': 'district', 628 | 'full_name': 'District Court, D. Puerto Rico', 629 | 'parent': 'ca1', 630 | 'short_name': 'D. Puerto Rico'}, 631 | 'rib': {'type': 'bankruptcy', 632 | 'full_name': 'United States Bankruptcy Court, D. Rhode Island', 633 | 'parent': None, 634 | 'short_name': 'D. Rhode Island'}, 635 | 'rid': {'type': 'district', 636 | 'full_name': 'District Court, D. Rhode Island', 637 | 'parent': 'ca1', 638 | 'short_name': 'D. Rhode Island'}, 639 | 'scb': {'type': 'bankruptcy', 640 | 'full_name': 'United States Bankruptcy Court, D. South Carolina', 641 | 'parent': None, 642 | 'short_name': 'D. South Carolina'}, 643 | 'scd': {'type': 'district', 644 | 'full_name': 'District Court, D. South Carolina', 645 | 'parent': 'ca4', 646 | 'short_name': 'D. South Carolina'}, 647 | 'scotus': {'type': 'appellate', 648 | 'full_name': 'Supreme Court of the United States', 649 | 'parent': None, 650 | 'short_name': 'Supreme Court'}, 651 | 'sdb': {'type': 'bankruptcy', 652 | 'full_name': 'United States Bankruptcy Court, D. South Dakota', 653 | 'parent': None, 654 | 'short_name': 'D. South Dakota'}, 655 | 'sdd': {'type': 'district', 656 | 'full_name': 'District Court, D. South Dakota', 657 | 'parent': 'ca8', 658 | 'short_name': 'D. South Dakota'}, 659 | 'tneb': {'type': 'bankruptcy', 660 | 'full_name': 'United States Bankruptcy Court, E.D. Tennessee', 661 | 'parent': None, 662 | 'short_name': 'E.D. Tennessee'}, 663 | 'tned': {'type': 'district', 664 | 'full_name': 'District Court, E.D. Tennessee', 665 | 'parent': 'ca6', 666 | 'short_name': 'E.D. Tennessee'}, 667 | 'tnmb': {'type': 'bankruptcy', 668 | 'full_name': 'United States Bankruptcy Court, M.D. Tennessee', 669 | 'parent': None, 670 | 'short_name': 'M.D. Tennessee'}, 671 | 'tnmd': {'type': 'district', 672 | 'full_name': 'District Court, M.D. Tennessee', 673 | 'parent': 'ca6', 674 | 'short_name': 'M.D. Tennessee'}, 675 | 'tnwb': {'type': 'bankruptcy', 676 | 'full_name': 'United States Bankruptcy Court, W.D. Tennessee', 677 | 'parent': None, 678 | 'short_name': 'W.D. Tennessee'}, 679 | 'tnwd': {'type': 'district', 680 | 'full_name': 'District Court, W.D. Tennessee', 681 | 'parent': 'ca6', 682 | 'short_name': 'W.D. Tennessee'}, 683 | 'txeb': {'type': 'bankruptcy', 684 | 'full_name': 'United States Bankruptcy Court, E.D. Texas', 685 | 'parent': None, 686 | 'short_name': 'E.D. Texas'}, 687 | 'txed': {'type': 'district', 688 | 'full_name': 'District Court, E.D. Texas', 689 | 'parent': 'ca5', 690 | 'short_name': 'E.D. Texas'}, 691 | 'txnb': {'type': 'bankruptcy', 692 | 'full_name': 'United States Bankruptcy Court, N.D. Texas', 693 | 'parent': None, 694 | 'short_name': 'N.D. Texas'}, 695 | 'txnd': {'type': 'district', 696 | 'full_name': 'District Court, N.D. Texas', 697 | 'parent': 'ca5', 698 | 'short_name': 'N.D. Texas'}, 699 | 'txsb': {'type': 'bankruptcy', 700 | 'full_name': 'United States Bankruptcy Court, S.D. Texas', 701 | 'parent': None, 702 | 'short_name': 'S.D. Texas'}, 703 | 'txsd': {'type': 'district', 704 | 'full_name': 'District Court, S.D. Texas', 705 | 'parent': 'ca5', 706 | 'short_name': 'S.D. Texas'}, 707 | 'txwb': {'type': 'bankruptcy', 708 | 'full_name': 'United States Bankruptcy Court, W.D. Texas', 709 | 'parent': None, 710 | 'short_name': 'W.D. Texas'}, 711 | 'txwd': {'type': 'district', 712 | 'full_name': 'District Court, W.D. Texas', 713 | 'parent': 'ca5', 714 | 'short_name': 'W.D. Texas'}, 715 | 'utb': {'type': 'bankruptcy', 716 | 'full_name': 'United States Bankruptcy Court, D. Utah', 717 | 'parent': None, 718 | 'short_name': 'D. Utah'}, 719 | 'utd': {'type': 'district', 720 | 'full_name': 'District Court, D. Utah', 721 | 'parent': 'ca10', 722 | 'short_name': 'D. Utah'}, 723 | 'vaeb': {'type': 'bankruptcy', 724 | 'full_name': 'United States Bankruptcy Court, E.D. Virginia', 725 | 'parent': None, 726 | 'short_name': 'E.D. Virginia'}, 727 | 'vaed': {'type': 'district', 728 | 'full_name': 'District Court, E.D. Virginia', 729 | 'parent': 'ca4', 730 | 'short_name': 'E.D. Virginia'}, 731 | 'vawb': {'type': 'bankruptcy', 732 | 'full_name': 'United States Bankruptcy Court, W.D. Virginia', 733 | 'parent': None, 734 | 'short_name': 'W.D. Virginia'}, 735 | 'vawd': {'type': 'district', 736 | 'full_name': 'District Court, W.D. Virginia', 737 | 'parent': 'ca4', 738 | 'short_name': 'W.D. Virginia'}, 739 | 'vib': {'type': 'bankruptcy', 740 | 'full_name': 'United States Bankruptcy Court, D. Virgin Islands', 741 | 'parent': None, 742 | 'short_name': 'D. Virgin Islands'}, 743 | 'vid': {'type': 'district', 744 | 'full_name': 'District Court, Virgin Islands', 745 | 'parent': 'ca3', 746 | 'short_name': 'Virgin Islands'}, 747 | 'vtb': {'type': 'bankruptcy', 748 | 'full_name': 'United States Bankruptcy Court, D. Vermont', 749 | 'parent': None, 750 | 'short_name': 'D. Vermont'}, 751 | 'vtd': {'type': 'district', 752 | 'full_name': 'District Court, D. Vermont', 753 | 'parent': 'ca2', 754 | 'short_name': 'D. Vermont'}, 755 | 'waeb': {'type': 'bankruptcy', 756 | 'full_name': 'United States Bankruptcy Court, E.D. Washington', 757 | 'parent': None, 758 | 'short_name': 'E.D. Washington'}, 759 | 'waed': {'type': 'district', 760 | 'full_name': 'District Court, E.D. Washington', 761 | 'parent': 'ca9', 762 | 'short_name': 'E.D. Washington'}, 763 | 'wawb': {'type': 'bankruptcy', 764 | 'full_name': 'United States Bankruptcy Court, W.D. Washington', 765 | 'parent': None, 766 | 'short_name': 'W.D. Washington'}, 767 | 'wawd': {'type': 'district', 768 | 'full_name': 'District Court, W.D. Washington', 769 | 'parent': 'ca9', 770 | 'short_name': 'W.D. Washington'}, 771 | 'wieb': {'type': 'bankruptcy', 772 | 'full_name': 'United States Bankruptcy Court, E.D. Wisconsin', 773 | 'parent': None, 774 | 'short_name': 'E.D. Wisconsin'}, 775 | 'wied': {'type': 'district', 776 | 'full_name': 'District Court, E.D. Wisconsin', 777 | 'parent': 'ca7', 778 | 'short_name': 'E.D. Wisconsin'}, 779 | 'wiwb': {'type': 'bankruptcy', 780 | 'full_name': 'United States Bankruptcy Court, W.D. Wisconsin', 781 | 'parent': None, 782 | 'short_name': 'W.D. Wisconsin'}, 783 | 'wiwd': {'type': 'district', 784 | 'full_name': 'District Court, W.D. Wisconsin', 785 | 'parent': 'ca7', 786 | 'short_name': 'W.D. Wisconsin'}, 787 | 'wvnb': {'type': 'bankruptcy', 788 | 'full_name': 'United States Bankruptcy Court, N.D. West Virginia', 789 | 'parent': None, 790 | 'short_name': 'N.D. West Virginia'}, 791 | 'wvnd': {'type': 'district', 792 | 'full_name': 'District Court, N.D. West Virginia', 793 | 'parent': 'ca4', 794 | 'short_name': 'N.D. West Virginia'}, 795 | 'wvsb': {'type': 'bankruptcy', 796 | 'full_name': 'United States Bankruptcy Court, S.D. West Virginia', 797 | 'parent': None, 798 | 'short_name': 'S.D. West Virginia'}, 799 | 'wvsd': {'type': 'district', 800 | 'full_name': 'District Court, S.D. West Virginia', 801 | 'parent': 'ca4', 802 | 'short_name': 'S.D. West Virginia'}, 803 | 'wyb': {'type': 'bankruptcy', 804 | 'full_name': 'United States Bankruptcy Court, D. Wyoming', 805 | 'parent': None, 806 | 'short_name': 'D. Wyoming'}, 807 | 'wyd': {'type': 'district', 808 | 'full_name': 'District Court, D. Wyoming', 809 | 'parent': 'ca10', 810 | 'short_name': 'D. Wyoming'}} 811 | --------------------------------------------------------------------------------