Change server details viewset

Change primary key to url instead of arbitrary id.
Queries for server details will now attempt to add the server if it doesn't exist.
This commit is contained in:
Corey 2024-05-03 03:35:36 +00:00
parent 9b0a1701d4
commit 739cfdd07e
13 changed files with 145 additions and 39 deletions

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.4 on 2024-05-02 22:25
import v1.models.server
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('v1', '0003_remove_server_inactivity_counter_server_up_and_more'),
]
operations = [
migrations.RemoveField(
model_name='server',
name='id',
),
migrations.AlterField(
model_name='server',
name='url',
field=models.CharField(max_length=255, primary_key=True, serialize=False, validators=[v1.models.server.OptionalSchemeURLValidator()]),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.0.4 on 2024-05-02 23:51
import v1.models.server
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('v1', '0004_remove_server_id_alter_server_url'),
]
operations = [
migrations.AlterField(
model_name='server',
name='url',
field=models.CharField(error_messages={'unique': 'A server with this URL already exists.'}, max_length=255, primary_key=True, serialize=False, validators=[v1.models.server.OptionalSchemeURLValidator()]),
),
]

View File

@ -1,14 +1,13 @@
import datetime import datetime
import json import json
import os
import socket
import requests import requests
from django.db import models from django.db import models
from django.core.validators import URLValidator from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from urllib.parse import urlparse from urllib.parse import urlparse
from v1.common.logging import logger from v1.common.logging import logger
from django.contrib.gis.geoip2 import GeoIP2 from django.contrib.gis.geoip2 import GeoIP2
from django.core.exceptions import ValidationError
# Required and recommended settings used to build the initial client server card # Required and recommended settings used to build the initial client server card
required_settings = [ required_settings = [
@ -39,9 +38,11 @@ class OptionalSchemeURLValidator(URLValidator):
class Server(models.Model): class Server(models.Model):
name = models.CharField(max_length=255, null=True, editable=False) name = models.CharField(max_length=255, null=True, editable=False)
url = models.CharField( url = models.CharField(
primary_key=True,
max_length=255, max_length=255,
null=False, null=False,
validators=[OptionalSchemeURLValidator()], validators=[OptionalSchemeURLValidator()],
error_messages={"unique": "A server with this URL already exists."},
) )
location = models.CharField(max_length=255, null=True, editable=False) location = models.CharField(max_length=255, null=True, editable=False)
max_level = models.IntegerField(null=True, editable=False) max_level = models.IntegerField(null=True, editable=False)
@ -55,8 +56,11 @@ class Server(models.Model):
@property @property
def expires(self): def expires(self):
try:
time_since_creation = self.updated - self.created time_since_creation = self.updated - self.created
return self.updated + min(time_since_creation, datetime.timedelta(hours=24)) return self.updated + min(time_since_creation, datetime.timedelta(hours=24))
except:
return datetime.datetime(0, 1, 1, 0, 0, 0)
def _str_(self): def _str_(self):
return self.url return self.url
@ -70,13 +74,10 @@ class Server(models.Model):
parsed_url = urlparse(self.url) parsed_url = urlparse(self.url)
self.url = parsed_url.netloc.lower() self.url = parsed_url.netloc.lower()
# Check if server URL is unique
existing_server = Server.objects.filter(url=self.url).first()
if existing_server and existing_server.pk != self.pk:
raise ValidationError("A server with this URL already exists.")
# Validate the server API # Validate the server API
if not self.parse_server_api(): if not self.parse_server_api():
if not self.created:
raise ValidationError("Server verification failed.")
if self.expires <= datetime.datetime.now(datetime.timezone.utc): if self.expires <= datetime.datetime.now(datetime.timezone.utc):
return False return False
self.up = False self.up = False

View File

@ -7,7 +7,6 @@ class ServerSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Server model = Server
fields = [ fields = [
"id",
"name", "name",
"url", "url",
"location", "location",

View File

@ -1,7 +1,11 @@
from django.http import Http404
from rest_framework import viewsets, mixins from rest_framework import viewsets, mixins
from rest_framework.response import Response
from django.db.models.fields.json import KT from django.db.models.fields.json import KT
from v1.serializers.server import ServerDetailsSerializer, ServerSerializer from v1.serializers.server import ServerDetailsSerializer, ServerSerializer
from v1.models.server import Server from v1.models.server import Server
from rest_framework import status
from django.core.exceptions import ValidationError
class ServerViewSet( class ServerViewSet(
@ -43,8 +47,53 @@ class ServerViewSet(
class ServerDetailsViewSet( class ServerDetailsViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet, viewsets.GenericViewSet,
): ):
queryset = Server.objects.all() queryset = Server.objects.all()
serializer_class = ServerDetailsSerializer serializer_class = ServerDetailsSerializer
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())
url_param = self.request.query_params.get("url")
if url_param is not None:
try:
return queryset.get(url=url_param)
except Server.DoesNotExist:
raise Http404("Server not found with the provided URL.")
return super().get_object()
def create_server(self, url_param):
try:
serializer = self.get_serializer(data={"url": url_param})
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
return serializer.instance
except ValidationError as e:
raise Http404(e.message)
def create(self, request, *args, **kwargs):
url_param = request.data.get("url")
if url_param:
try:
server = Server.objects.get(url=url_param)
serializer = self.get_serializer(server)
return Response(serializer.data, status=status.HTTP_200_OK)
except Server.DoesNotExist:
instance = self.create_server(url_param)
serializer = self.get_serializer(instance)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
url_param = self.request.query_params.get("url")
if url_param is not None:
queryset = queryset.filter(url=url_param)
if not queryset.exists():
instance = self.create_server(url_param)
serializer = self.get_serializer(instance)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return self.retrieve(request, *args, **kwargs)
raise Http404("No URL provided.")

View File

@ -39,7 +39,7 @@ export function App() {
element={<Home servers={servers} searchState={searchState} />} element={<Home servers={servers} searchState={searchState} />}
/> />
<Route <Route
path="/server/:id" path="/server/:url"
element={<ServerDetails setAlertInfo={setAlertInfo} />} element={<ServerDetails setAlertInfo={setAlertInfo} />}
/> />
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />

View File

@ -26,23 +26,34 @@ export const fetchDataFromBackend = async (
queryParams = {} queryParams = {}
) => { ) => {
const queryString = new URLSearchParams(queryParams).toString(); const queryString = new URLSearchParams(queryParams).toString();
const url = `${import.meta.env.PROD ? 'https://api.ixion.dev' : 'http://localhost:8000'}/${dir}/${queryString ? `?${queryString}` : ''}`; const url = `${import.meta.env.PROD ? 'https://api.ixion.dev' : 'http://localhost:8000'}/${dir}${queryString ? `?${queryString}` : ''}`;
try { try {
const response = await fetch(url, { const response = await fetch(url, {
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error( let errorMessage =
`(${response.status.toString()} - ${response.statusText || 'unknown'})` 'An unknown error occurred while fetching data from the backend.';
);
// Check if the response has JSON content
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
errorMessage = errorData.url || errorData.detail;
}
throw new Error(errorMessage);
} }
return await response.json(); return await response.json();
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
throw new Error(`${err.message} - ${url}`); throw new Error(err.message);
} else { } else {
throw new Error('Something went wrong.'); throw new Error(
'An unhandled error occurred while fetching data from the backend.'
);
} }
} }
}; };
@ -59,7 +70,7 @@ export const postData = async (inputText: string) => {
body: JSON.stringify({ url: inputText }), body: JSON.stringify({ url: inputText }),
} }
); );
let message = `Server verification failed!`; let message = `An unknown error occurred while sending data to the backend.`;
let success = false; let success = false;
if (!response.ok) { if (!response.ok) {
// bad url or duplicate // bad url or duplicate
@ -73,13 +84,13 @@ export const postData = async (inputText: string) => {
}; };
} }
const responseData = await response.json(); const responseData = await response.json();
if (responseData.id !== null) { if (response.status === 201) {
message = 'Added new server!'; message = 'Added new server!';
success = true; success = true;
} }
return { message, success, data: responseData }; return { message, success, data: responseData };
} catch (err) { } catch (err) {
let message = 'An unknown error occurred.'; let message = 'An unhandled error while sending data to the backend.';
if (err instanceof Error) { if (err instanceof Error) {
message = err.message; message = err.message;
} }

View File

@ -73,7 +73,7 @@ export default function AddServer({
severity: 'success', severity: 'success',
}); });
if (response.data && typeof response.data === 'object') { if (response.data && typeof response.data === 'object') {
navigate(`/server/${response.data.id}`); navigate(`/server/${encodeURIComponent(response.data.url)}`);
setServers([response.data, ...servers]); setServers([response.data, ...servers]);
} }
if (inputRef.current) { if (inputRef.current) {
@ -119,7 +119,11 @@ export default function AddServer({
<StyledInputBase <StyledInputBase
inputRef={inputRef} inputRef={inputRef}
placeholder="URL" placeholder="URL"
inputProps={{ 'aria-label': 'add-server' }} autoComplete="off"
inputProps={{
name: 'server-url',
'aria-label': 'add-server',
}}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
disabled={isLoading} disabled={isLoading}
endAdornment={ endAdornment={

View File

@ -183,7 +183,7 @@ export default function SearchServers({
flexDirection="column" flexDirection="column"
alignItems="center" alignItems="center"
> >
<Typography id="max-level-slider" variant="caption"> <Typography id="max-level" variant="caption">
Max Level Max Level
</Typography> </Typography>
</Grid> </Grid>
@ -194,15 +194,16 @@ export default function SearchServers({
onChange={handleSearchMaxLevelMin} onChange={handleSearchMaxLevelMin}
// onBlur={handleBlur} // onBlur={handleBlur}
inputProps={{ inputProps={{
name: 'max-level-min',
step: 1, step: 1,
min: 1, min: 1,
max: 99, max: 99,
type: 'number', type: 'number',
'aria-labelledby': 'max-level-min', 'aria-labelledby': 'max-level',
}} }}
/> />
<Slider <Slider
aria-labelledby="max-level-slider" aria-labelledby="max-level"
value={searchState.maxLevel} value={searchState.maxLevel}
onChange={handleSearchMaxLevel} onChange={handleSearchMaxLevel}
valueLabelDisplay="auto" valueLabelDisplay="auto"
@ -218,11 +219,12 @@ export default function SearchServers({
onChange={handleSearchMaxLevelMax} onChange={handleSearchMaxLevelMax}
// onBlur={handleBlur} // onBlur={handleBlur}
inputProps={{ inputProps={{
name: 'max-level-max',
step: 1, step: 1,
min: 1, min: 1,
max: 99, max: 99,
type: 'number', type: 'number',
'aria-labelledby': 'max-level-max', 'aria-labelledby': 'max-level',
}} }}
/> />
</Grid> </Grid>

View File

@ -330,7 +330,7 @@ export default function ServerCard({ server }: { server: ServerData }) {
<Tooltip title="View full settings." arrow disableInteractive> <Tooltip title="View full settings." arrow disableInteractive>
<IconButton <IconButton
component={Link} component={Link}
to={`/server/${server.id}`} to={`/server/${encodeURIComponent(server.url)}`}
onClick={scrollToTop} onClick={scrollToTop}
className="p-0" className="p-0"
disableRipple disableRipple

View File

@ -12,7 +12,6 @@ export interface ServerSettings {
} }
export default interface ServerData { export default interface ServerData {
id: number;
name: string; name: string;
url: string; url: string;
location: string; location: string;
@ -26,7 +25,6 @@ export default interface ServerData {
} }
export const DemoServerData: ServerData = { export const DemoServerData: ServerData = {
id: 0,
name: 'LandSandBoat Demo', name: 'LandSandBoat Demo',
url: 'github.com/LandSandBoat/server', url: 'github.com/LandSandBoat/server',
location: 'NA', location: 'NA',

View File

@ -129,7 +129,7 @@ export default function Home({
<ErrorCard error="" /> <ErrorCard error="" />
) : ( ) : (
filteredServers.map((server: ServerData) => ( filteredServers.map((server: ServerData) => (
<ServerCard key={server.id} server={server} /> <ServerCard key={server.url} server={server} />
)) ))
)} )}
</Box> </Box>

View File

@ -30,7 +30,7 @@ export default function ServerDetails({
}: { }: {
setAlertInfo: React.Dispatch<React.SetStateAction<AlertResponse>>; setAlertInfo: React.Dispatch<React.SetStateAction<AlertResponse>>;
}) { }) {
const { id } = useParams(); const { url } = useParams();
const [server, setServer] = useState<ServerData>(); const [server, setServer] = useState<ServerData>();
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
@ -38,10 +38,10 @@ export default function ServerDetails({
let data: ServerData; let data: ServerData;
const fetchServerData = async () => { const fetchServerData = async () => {
try { try {
if (id === 'demo') { if (url === 'demo') {
data = await fetchDemo(); data = await fetchDemo();
} else { } else {
data = await fetchDataFromBackend(`server/${id}`); data = await fetchDataFromBackend(`server/?url=${url}`);
} }
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
@ -58,7 +58,7 @@ export default function ServerDetails({
}; };
fetchServerData(); fetchServerData();
}, [id, setServer, setAlertInfo, setError]); }, [url, setServer, setAlertInfo, setError]);
const [clipboardTooltip, setClipboardTooltip] = useState('Copy server URL.'); const [clipboardTooltip, setClipboardTooltip] = useState('Copy server URL.');
const [clipboardTooltipOpen, setClipboardTooltipOpen] = useState(false); const [clipboardTooltipOpen, setClipboardTooltipOpen] = useState(false);
@ -93,12 +93,12 @@ export default function ServerDetails({
} }
}; };
function formatExternalUrl(url: string): string { function formatExternalUrl(serverUrl: string): string {
// prepend 'https://' to the URL if it's not already there // prepend 'https://' to the URL if it's not already there
if (!/^https?:\/\//i.test(url)) { if (!/^https?:\/\//i.test(serverUrl)) {
return `https://${url}`; return `https://${serverUrl}`;
} }
return url; return serverUrl;
} }
return ( return (