Skip to content
Open
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
Empty file.
7 changes: 7 additions & 0 deletions queryzen-api/apps/authentication/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# pylint: disable=C0114
from django.apps import AppConfig


class AuthConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.authentication'
31 changes: 31 additions & 0 deletions queryzen-api/apps/authentication/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 5.1.6 on 2025-07-24 15:07

import uuid
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='QueryzenUser',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('email', models.EmailField(max_length=254, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_superuser', models.BooleanField(default=False)),
('is_staff', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
],
options={
'abstract': False,
},
),
]
Empty file.
52 changes: 52 additions & 0 deletions queryzen-api/apps/authentication/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# pylint: disable=C0114
from apps.shared.mixins import UUIDMixin
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.db import models


class QueryzenUserManager(BaseUserManager):
"""Custom manager for QueryzenUser model."""
def create_user(self, email, password, **extra_fields):
"""Create and return a regular user with the given email and password."""
if not email:
raise ValueError('Users must have an email address')
if not password:
raise ValueError('Users must have a password')

email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user

def create_superuser(self, email, password, **extra_fields):
"""Create and return a superuser with the given email and password."""
extra_fields.setdefault('is_superuser', True)
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_active', True)

return self.create_user(email, password, **extra_fields)


class QueryzenUser(AbstractBaseUser, UUIDMixin):
"""Custom user model that uses email as the unique identifier."""
email = models.EmailField(unique=True)
created_at = models.DateTimeField(auto_now_add=True)

is_superuser = models.BooleanField(default=False)
is_staff = models.BooleanField(default=False) # Needed for admin access
is_active = models.BooleanField(default=True) # Needed for login system

objects = QueryzenUserManager()

USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []

def __str__(self):
return str(self.email)

def has_perm(self):
return self.is_superuser

def has_module_perms(self):
return self.is_superuser
11 changes: 11 additions & 0 deletions queryzen-api/apps/authentication/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# pylint: disable=C0114
from django.urls import path
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)

urlpatterns = [
path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
4 changes: 4 additions & 0 deletions queryzen-api/apps/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.shortcuts import get_object_or_404

from django_filters import rest_framework as filters
from rest_framework.permissions import IsAuthenticated

from rest_framework.response import Response
from rest_framework import mixins, viewsets, status, views
Expand Down Expand Up @@ -33,6 +34,7 @@ class ZenFilterViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):

Check ``QueryZenFilter.Meta.fields`` to see the available ones.
"""
permission_classes = (IsAuthenticated,)
queryset = Zen.objects.all()
serializer_class = ZenSerializer
filter_backends = (filters.DjangoFilterBackend,)
Expand All @@ -46,6 +48,7 @@ class ZenView(views.APIView):
PUT: Create a Zen.
DELETE: Delete a Zen.
"""
permission_classes = (IsAuthenticated,)

def _validate_parameters_replacement(self, zen: Zen, parameters: dict) -> None:
"""Validates that the required parameters to run the query are given by the user
Expand Down Expand Up @@ -153,6 +156,7 @@ def delete(self, request, collection: str, name: str, version: str): # pylint:

class StatisticsView(views.APIView):
"""View to retrieve statistical execution time metrics for a given Zen version."""
permission_classes = (IsAuthenticated,)

def get(self, request, collection: str, name: str, version: str): # pylint: disable=W0613
"""
Expand Down
9 changes: 9 additions & 0 deletions queryzen-api/apps/testing/views.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# pylint: skip-file
from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password

from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response

User = get_user_model()


@api_view(('GET',))
def clean_up_db(request):
Expand All @@ -16,4 +21,8 @@ def clean_up_db(request):
model.objects.all().delete()
else:
assert False

# Create a new testing user for auth
User.objects.get_or_create(email="test@test.com", password=make_password('test'))

return Response(status=status.HTTP_200_OK)
1 change: 1 addition & 0 deletions queryzen-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [
"httpx>=0.28.1,<0.29",
"psycopg2-binary>=2.9.10",
"django-cors-headers>=4.7.0",
"djangorestframework-simplejwt>=5.5.1",
]

[dependency-groups]
Expand Down
8 changes: 8 additions & 0 deletions queryzen-api/queryzen_api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,13 @@
# Application definition
INSTALLED_APPS = [
'apps.core',
'apps.authentication',

'rest_framework',
'django_filters',
'django_celery_results',
'corsheaders',
'rest_framework_simplejwt',

'django.contrib.admin',
'django.contrib.auth',
Expand Down Expand Up @@ -154,6 +156,9 @@
'DEFAULT_FILTER_BACKENDS': (
'django_filters.rest_framework.DjangoFilterBackend',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
)
}
if not DEBUG:
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
Expand All @@ -174,3 +179,6 @@
CORS_ALLOWED_ORIGINS = get_split_env('CORS_ALLOWED_ORIGINS', [])
CORS_ALLOWED_ORIGIN_REGEXES = get_split_env('CORS_ALLOWED_ORIGIN_REGEXES', [])
CORS_ALLOW_ALL_ORIGINS = strtobool(os.getenv('CORS_ALLOW_ALL_ORIGINS', 'False'))

#### Authentication section ####
AUTH_USER_MODEL = 'authentication.QueryzenUser'
1 change: 1 addition & 0 deletions queryzen-api/queryzen_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

urlpatterns = [
path('', include('apps.core.urls')),
path('', include('apps.authentication.urls')),
path('_healthcheck', lambda r: HttpResponse()),
]

Expand Down
25 changes: 25 additions & 0 deletions queryzen-api/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 27 additions & 4 deletions queryzen-client/queryzen/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

from . import constants
from .constants import DEFAULT_COLLECTION
from .exceptions import AuthenticationError
from .http_wrapper import HttpxWrapper
from .types import _AUTO, AUTO, Default


Expand Down Expand Up @@ -115,11 +117,31 @@ class QueryZenHttpClient(QueryZenClientABC):
COLLECTIONS = 'collection/'
VERSION = 'version/'

def __init__(self, client: httpx.Client = None):
self.client: httpx.Client = (client
or httpx.Client(timeout=int(constants.DEFAULT_HTTP_TIMEOUT)))
def __init__(self, user: str = None, password: str = None, client: httpx.Client = None):
self.client: HttpxWrapper = HttpxWrapper(
(client or httpx.Client(timeout=int(constants.DEFAULT_HTTP_TIMEOUT)))
)
self.url: Url = Url(constants.BACKEND_URL or constants.LOCAL_URL)

# Everytime a QueryZenHttpClient is declared, a new pair is generated
# It'd be interesting to keep this in mind for future auto refresh features
# For the moment, clients won't be open so much time
self.access_token, self.refresh_token = self.get_jwt_pair(user, password)

self.client.access_token = self.access_token

def get_jwt_pair(self, email: str, password: str) -> (str, str):
"""Authenticates user against queryzen auth service"""
response = self.client.post(
self.url / 'auth/token/', json={'email': email, 'password': password})

if response.status_code == 401:
raise AuthenticationError('Authentication failed. Please check your credentials.')

payload = response.json()
return payload['access'], payload['refresh']


def make_url(self, collection: str, name: str, version: str) -> str:
# todo make test
"""Creates a valid QueryZen REST url
Expand Down Expand Up @@ -198,7 +220,8 @@ def create(self,
return self.make_response(response)

def filter(self, **filters) -> QueryZenResponse:
response = httpx.get(self.url / self.MAIN_ENDPOINT / '?' + urllib.parse.urlencode(filters))
response = self.client.get(
self.url / self.MAIN_ENDPOINT / '?' + urllib.parse.urlencode(filters))
return self.make_response(response)

def get(self,
Expand Down
3 changes: 3 additions & 0 deletions queryzen-client/queryzen/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class IncompatibleAPIError(Exception):
"""
# Todo add message

class AuthenticationError(Exception):
"""Authentication failed."""


class ExecutionEngineError(Exception):
"""Workers or the broker is unavailable."""
Expand Down
34 changes: 34 additions & 0 deletions queryzen-client/queryzen/http_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""
This module defines a wrapper class for httpx.Client to handle
authenticated HTTP requests with optional bearer token support.
"""
import httpx


class HttpxWrapper:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure if wee need a wrapper over httpx just to add a header token

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'll be useful for future features such as who run the query (python-client, through http directly...)

"""
Wrapper around httpx.Client to provide centralized handling of
authentication headers and request execution logic.
"""
access_token: str | None = None

def __init__(self, client: httpx.Client | None = None, **kwargs):
self._client = client or httpx.Client(**kwargs)

def _get_headers(self) -> dict[str, str]:
return {'Authorization': f'Bearer {self.access_token}'} if self.access_token else {}

def _handle_request(self, method: str, url: str, **kwargs):
return getattr(self._client, method)(url, headers=self._get_headers(), **kwargs)

def get(self, url, **kwargs):
return self._handle_request('get', url, **kwargs)

def post(self, url, **kwargs):
return self._handle_request('post', url, **kwargs)

def put(self, url, **kwargs):
return self._handle_request('put', url, **kwargs)

def delete(self, url, **kwargs):
return self._handle_request('delete', url, **kwargs)
12 changes: 9 additions & 3 deletions queryzen-client/queryzen/queryzen.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
MissingParametersError,
DatabaseDoesNotExistError,
DefaultValueDoesNotExistError,
ParametersMissmatchError)
ParametersMissmatchError, AuthenticationError)
from .types import AUTO, Rows, Columns, _AUTO, Default, ZenState
from .constants import DEFAULT_COLLECTION
from .table import make_table, ColumnCenter
Expand Down Expand Up @@ -255,8 +255,11 @@ class QueryZen:
```
"""

def __init__(self, client: QueryZenClientABC | None = None):
self._client: QueryZenClientABC = client or QueryZenHttpClient()
def __init__(
self, user: str = None,
password: str = None,
client: QueryZenClientABC | None = None):
self._client: QueryZenClientABC = client or QueryZenHttpClient(user, password)

def _validate_version(self, version) -> str:
"""
Expand Down Expand Up @@ -547,6 +550,9 @@ def run(self,
if response.error_code == 400:
raise MissingParametersError(response.error)

if response.error_code == 401:
raise AuthenticationError()

if response.error_code == 409:
raise ParametersMissmatchError(response.error)

Expand Down
Loading