Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support multiple changeset BBoxes and more #92

Merged
merged 20 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
# Auto detect text files and perform LF normalization
* text=auto

app/alembic/versions/* binary
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Last update: 2024-03-17
- Element view supports pagination and icons for part of, members, and nodes
- Replace markdown formatter with [CommonMark](https://spec.commonmark.org/)-compliant [implementation](https://github.com/executablebooks/markdown-it-py)
- Search query now uses commonly used `q` parameter instead of `query`
- Where-is-this will now remember zoom level in the URL (affects the result)
- Where-is-this supports refreshing with "search this area" button
- Improved search experience

## Frontend
Expand Down

This file was deleted.

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,20 @@ def _path(s: str, *, mkdir: bool = False) -> Path:


# Configuration (optional)
TEST_ENV = os.getenv('TEST_ENV', '0').strip().lower() in ('1', 'true', 'yes')
TEST_ENV = os.getenv('TEST_ENV', '0').strip().lower() in {'1', 'true', 'yes'}
LOG_LEVEL = os.getenv('LOG_LEVEL', 'DEBUG' if TEST_ENV else 'INFO').upper()
GC_LOG = os.getenv('GC_LOG', '0').strip().lower() in ('1', 'true', 'yes')
GC_LOG = os.getenv('GC_LOG', '0').strip().lower() in {'1', 'true', 'yes'}

LEGACY_HIGH_PRECISION_TIME = os.getenv('LEGACY_HIGH_PRECISION_TIME', '0').strip().lower() in ('1', 'true', 'yes')
LEGACY_SEQUENCE_ID_MARGIN = os.getenv('LEGACY_SEQUENCE_ID_MARGIN', '0').strip().lower() in ('1', 'true', 'yes')
LEGACY_HIGH_PRECISION_TIME = os.getenv('LEGACY_HIGH_PRECISION_TIME', '0').strip().lower() in {'1', 'true', 'yes'}
LEGACY_SEQUENCE_ID_MARGIN = os.getenv('LEGACY_SEQUENCE_ID_MARGIN', '0').strip().lower() in {'1', 'true', 'yes'}

FILE_CACHE_DIR = _path(os.getenv('FILE_CACHE_DIR', 'data/cache'), mkdir=True)
FILE_CACHE_SIZE_GB = int(os.getenv('FILE_CACHE_SIZE_GB', '128'))
FILE_STORE_DIR = _path(os.getenv('FILE_STORE_DIR', 'data/store'), mkdir=True)
PRELOAD_DIR = _path(os.getenv('PRELOAD_DIR', 'data/preload'))

# see for options: https://docs.sqlalchemy.org/en/20/dialects/postgresql.html#module-sqlalchemy.dialects.postgresql.asyncpg
POSTGRES_LOG = os.getenv('POSTGRES_LOG', '0').strip().lower() in ('1', 'true', 'yes')
POSTGRES_LOG = os.getenv('POSTGRES_LOG', '0').strip().lower() in {'1', 'true', 'yes'}
POSTGRES_URL = 'postgresql+asyncpg://' + os.getenv(
'POSTGRES_URL', f'postgres:postgres@/postgres?host={_path('data/postgres_unix')}'
)
Expand Down
5 changes: 3 additions & 2 deletions app/controllers/api06_changeset.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import cython
from fastapi import APIRouter, Query, Response, status
from pydantic import PositiveInt
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import joinedload, raiseload

from app.format import Format06
from app.lib.auth_context import api_user
Expand Down Expand Up @@ -193,14 +193,15 @@ async def query_changesets(
closed_after = None
created_before = None

with options_context(joinedload(Changeset.user).load_only(User.display_name)):
with options_context(joinedload(Changeset.user).load_only(User.display_name), raiseload(Changeset.bounds)):
changesets = await ChangesetQuery.find_many_by_query(
changeset_ids=changeset_ids,
user_id=user.id if (user is not None) else None,
created_before=created_before,
closed_after=closed_after,
is_open=True if open else (False if closed else None),
geometry=geometry,
legacy_geometry=True,
sort='asc' if (order == 'newest') else 'desc',
limit=limit,
)
Expand Down
5 changes: 3 additions & 2 deletions app/controllers/feed_changeset.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from feedgen.feed import FeedGenerator
from pydantic import PositiveInt
from shapely.geometry.base import BaseGeometry
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import joinedload, raiseload
from starlette import status

from app.config import APP_URL, ATTRIBUTION_URL
Expand Down Expand Up @@ -64,10 +64,11 @@ async def _get_feed(
geometry: BaseGeometry | None,
limit: int,
):
with options_context(joinedload(Changeset.user).load_only(User.display_name)):
with options_context(joinedload(Changeset.user).load_only(User.display_name), raiseload(Changeset.bounds)):
changesets = await ChangesetQuery.find_many_by_query(
user_id=user.id if (user is not None) else None,
geometry=geometry,
legacy_geometry=True,
sort='desc',
limit=limit,
)
Expand Down
5 changes: 3 additions & 2 deletions app/controllers/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from app.lib.locale import is_valid_locale
from app.lib.render_response import render_response
from app.lib.translation import primary_translation_locale, t, translation_context
from app.limits import URLSAFE_BLACKLIST
from app.models.db.user import User

router = APIRouter()
Expand Down Expand Up @@ -119,7 +120,7 @@ async def login():
async def signup():
if auth_user() is not None:
return RedirectResponse('/', status.HTTP_303_SEE_OTHER)
return render_response('user/signup.jinja2')
return render_response('user/signup.jinja2', {'URLSAFE_BLACKLIST': URLSAFE_BLACKLIST})


@router.get('/welcome')
Expand All @@ -129,4 +130,4 @@ async def welcome(_: Annotated[User, web_user()]):

@router.get('/settings')
async def settings(_: Annotated[User, web_user()]):
return render_response('user/settings/index.jinja2')
return render_response('user/settings/index.jinja2', {'URLSAFE_BLACKLIST': URLSAFE_BLACKLIST})
2 changes: 1 addition & 1 deletion app/controllers/partial_changeset.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ async def adjacent_ids_task():
'params': JSON_ENCODE(
{
'id': id,
**({'bounds': changeset.bounds.bounds} if (changeset.bounds is not None) else {}),
'bounds': tuple(cb.bounds.bounds for cb in changeset.bounds),
'elements': elements,
}
).decode(),
Expand Down
42 changes: 38 additions & 4 deletions app/controllers/partial_search.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from asyncio import TaskGroup
from collections.abc import Collection
from itertools import chain
from typing import Annotated

import cython
import numpy as np
from fastapi import APIRouter, Query
from shapely import lib
from shapely import Point, lib

from app.format import FormatLeaflet
from app.lib.render_response import render_response
Expand All @@ -17,17 +18,19 @@
from app.models.db.element import Element
from app.models.element_ref import ElementRef
from app.models.element_type import ElementType
from app.models.geometry import Latitude, Longitude, Zoom
from app.models.msgspec.leaflet import ElementLeaflet, ElementLeafletNode
from app.models.search_result import SearchResult
from app.queries.element_member_query import ElementMemberQuery
from app.queries.element_query import ElementQuery
from app.queries.nominatim_query import NominatimQuery
from app.utils import JSON_ENCODE

router = APIRouter(prefix='/api/partial/search')
router = APIRouter(prefix='/api/partial')


@router.get('/')
async def search(
@router.get('/search')
async def get_search(
query: Annotated[str, Query(alias='q', min_length=1, max_length=SEARCH_QUERY_MAX_LENGTH)],
bbox: Annotated[str, Query(min_length=1)],
local_only: Annotated[bool, Query()] = False,
Expand All @@ -52,7 +55,37 @@ async def search(
task_index = Search.best_results_index(task_results)
bounds = search_bounds[task_index][0]
results = Search.deduplicate_similar_results(task_results[task_index])
return await _get_response(
at_sequence_id=at_sequence_id,
bounds=bounds,
results=results,
where_is_this=False,
)


@router.get('/where-is-this')
async def get_where_is_this(
lon: Annotated[Longitude, Query()],
lat: Annotated[Latitude, Query()],
zoom: Annotated[Zoom, Query()],
):
result = await NominatimQuery.reverse(Point(lon, lat), zoom)
results = (result,) if (result is not None) else ()
return await _get_response(
at_sequence_id=None,
bounds='',
results=results,
where_is_this=True,
)


async def _get_response(
*,
at_sequence_id: int | None,
bounds: str,
results: Collection[SearchResult],
where_is_this: bool,
):
elements = tuple(r.element for r in results)
await ElementMemberQuery.resolve_members(elements)

Expand Down Expand Up @@ -104,5 +137,6 @@ async def search(
'bounds': bounds,
'results': results,
'leaflet': JSON_ENCODE(leaflet).decode(),
'where_is_this': where_is_this,
},
)
5 changes: 3 additions & 2 deletions app/controllers/web_system_app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Annotated
from typing import Annotated, Literal

from fastapi import APIRouter, Form

Expand All @@ -9,9 +9,10 @@
router = APIRouter(prefix='/api/web/system-app')


# TODO: test id, rapid only
@router.post('/create-access-token')
async def create_access_token(
client_id: Annotated[str, Form()],
client_id: Annotated[Literal['SystemApp.id', 'SystemApp.rapid'], Form()],
_: Annotated[User, web_user()],
):
access_token = await SystemAppService.create_access_token(client_id)
Expand Down
10 changes: 5 additions & 5 deletions app/controllers/web_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from app.models.str import DisplayNameStr, EmailStr, PasswordStr
from app.models.user_status import UserStatus
from app.queries.user_query import UserQuery
from app.services.auth_service import AuthService
from app.services.oauth2_token_service import OAuth2TokenService
from app.services.user_service import UserService
from app.services.user_signup_service import UserSignupService
from app.services.user_token_account_confirm_service import UserTokenAccountConfirmService
Expand All @@ -40,13 +40,13 @@ async def login(
password: Annotated[PasswordStr, Form()],
remember: Annotated[bool, Form()] = False,
):
token = await UserService.login(
access_token = await UserService.login(
display_name_or_email=display_name_or_email,
password=password,
)
max_age = COOKIE_AUTH_MAX_AGE if remember else None
response = Response()
response.set_cookie('auth', str(token), max_age, secure=not TEST_ENV, httponly=True, samesite='lax')
response.set_cookie('auth', access_token, max_age, secure=not TEST_ENV, httponly=True, samesite='lax')
return response


Expand All @@ -55,8 +55,8 @@ async def logout(
request: Request,
_: Annotated[User, web_user()],
):
token_struct = UserTokenStruct.from_str(request.cookies['auth'])
await AuthService.destroy_session(token_struct)
access_token = request.cookies['auth']
await OAuth2TokenService.revoke_by_token(access_token)
response = redirect_referrer() # TODO: auto redirect instead of unauthorized for web user
response.delete_cookie('auth')
return response
Expand Down
28 changes: 0 additions & 28 deletions app/exceptions/auth_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,34 +28,6 @@ def bad_user_token_struct(self) -> NoReturn:
def bad_basic_auth_format(self) -> NoReturn:
raise NotImplementedError

@abstractmethod
def oauth1_timestamp_out_of_range(self) -> NoReturn:
raise NotImplementedError

@abstractmethod
def oauth1_nonce_missing(self) -> NoReturn:
raise NotImplementedError

@abstractmethod
def oauth1_bad_nonce(self) -> NoReturn:
raise NotImplementedError

@abstractmethod
def oauth1_nonce_used(self) -> NoReturn:
raise NotImplementedError

@abstractmethod
def oauth1_bad_verifier(self) -> NoReturn:
raise NotImplementedError

@abstractmethod
def oauth1_unsupported_signature_method(self, method: str) -> NoReturn:
raise NotImplementedError

@abstractmethod
def oauth1_bad_signature(self) -> NoReturn:
raise NotImplementedError

@abstractmethod
def oauth2_bearer_missing(self) -> NoReturn:
raise NotImplementedError
Expand Down
28 changes: 0 additions & 28 deletions app/exceptions06/auth_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,34 +28,6 @@ def insufficient_scopes(self, scopes: Iterable[str]) -> NoReturn:
def bad_basic_auth_format(self) -> NoReturn:
raise APIError(status.HTTP_400_BAD_REQUEST, detail='Malformed basic auth credentials')

@override
def oauth1_timestamp_out_of_range(self) -> NoReturn:
raise APIError(status.HTTP_400_BAD_REQUEST, detail='OAuth timestamp out of range')

@override
def oauth1_nonce_missing(self) -> NoReturn:
raise APIError(status.HTTP_400_BAD_REQUEST, detail='OAuth nonce missing')

@override
def oauth1_bad_nonce(self) -> NoReturn:
raise APIError(status.HTTP_400_BAD_REQUEST, detail='OAuth nonce invalid')

@override
def oauth1_nonce_used(self) -> NoReturn:
raise APIError(status.HTTP_401_UNAUTHORIZED, detail='OAuth nonce already used')

@override
def oauth1_bad_verifier(self) -> NoReturn:
raise APIError(status.HTTP_401_UNAUTHORIZED, detail='OAuth verifier invalid')

@override
def oauth1_unsupported_signature_method(self, method: str) -> NoReturn:
raise APIError(status.HTTP_400_BAD_REQUEST, detail=f'OAuth unsupported signature method {method!r}')

@override
def oauth1_bad_signature(self) -> NoReturn:
raise APIError(status.HTTP_401_UNAUTHORIZED, detail='OAuth signature invalid')

@override
def oauth2_bearer_missing(self) -> NoReturn:
raise APIError(status.HTTP_401_UNAUTHORIZED, detail='OAuth2 bearer authorization header missing')
Expand Down
4 changes: 2 additions & 2 deletions app/format/api06_changeset.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ def _encode_changeset(changeset: Changeset, *, is_json: cython.char) -> dict:
>>> _encode_changeset(Changeset(...))
{'@id': 1, '@created_at': ..., ..., 'discussion': {'comment': [...]}}
"""
if changeset.bounds is not None:
if changeset.union_bounds is not None:
xattr = get_xattr(is_json=is_json)
minx, miny, maxx, maxy = changeset.bounds.bounds
minx, miny, maxx, maxy = changeset.union_bounds.bounds
bounds_dict = {
xattr('minlon', xml='min_lon'): minx,
xattr('minlat', xml='min_lat'): miny,
Expand Down
4 changes: 2 additions & 2 deletions app/format/api06_changeset_rss.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ def _encode_changeset(fg: FeedGenerator, changeset: Changeset):
user_display_name = None
user_permalink = None

if changeset.bounds is not None:
minx, miny, maxx, maxy = changeset.bounds.bounds
if changeset.union_bounds is not None:
minx, miny, maxx, maxy = changeset.union_bounds.bounds
fe.geo.box(f'{miny} {minx} {maxy} {maxx}')

fe.content(
Expand Down
Loading