add lab2
This commit is contained in:
parent
2414f2f761
commit
2c08bf7282
381
MusicBand_LoadTest.jmx
Normal file
381
MusicBand_LoadTest.jmx
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
|
||||
<hashTree>
|
||||
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="Music Band IS Load Test" enabled="true">
|
||||
<stringProp name="TestPlan.comments"></stringProp>
|
||||
<boolProp name="TestPlan.functional_mode">false</boolProp>
|
||||
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
|
||||
<elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
|
||||
<collectionProp name="Arguments.arguments">
|
||||
<elementProp name="BASE_URL" elementType="Argument">
|
||||
<stringProp name="Argument.name">BASE_URL</stringProp>
|
||||
<stringProp name="Argument.value">http://localhost:8080/is1/api</stringProp>
|
||||
<stringProp name="Argument.metadata">=</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</elementProp>
|
||||
<stringProp name="TestPlan.user_define_classpath"></stringProp>
|
||||
</TestPlan>
|
||||
<hashTree>
|
||||
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="1. User Registration" enabled="true">
|
||||
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
|
||||
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
|
||||
<boolProp name="LoopController.continue_forever">false</boolProp>
|
||||
<stringProp name="LoopController.loops">1</stringProp>
|
||||
</elementProp>
|
||||
<stringProp name="ThreadGroup.num_threads">10</stringProp>
|
||||
<stringProp name="ThreadGroup.ramp_time">2</stringProp>
|
||||
<longProp name="ThreadGroup.start_time">1</longProp>
|
||||
<longProp name="ThreadGroup.end_time">1</longProp>
|
||||
<boolProp name="ThreadGroup.scheduler">false</boolProp>
|
||||
<stringProp name="ThreadGroup.duration"></stringProp>
|
||||
<stringProp name="ThreadGroup.delay"></stringProp>
|
||||
</ThreadGroup>
|
||||
<hashTree>
|
||||
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Register User" enabled="true">
|
||||
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
|
||||
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
|
||||
<collectionProp name="Arguments.arguments">
|
||||
<elementProp name="" elementType="HTTPArgument">
|
||||
<boolProp name="HTTPArgument.always_encode">false</boolProp>
|
||||
<stringProp name="Argument.value">{
|
||||
"username": "testuser${__threadNum}",
|
||||
"password": "password123"
|
||||
}</stringProp>
|
||||
<stringProp name="Argument.metadata">=</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</elementProp>
|
||||
<stringProp name="HTTPSampler.domain">localhost</stringProp>
|
||||
<stringProp name="HTTPSampler.port">8080</stringProp>
|
||||
<stringProp name="HTTPSampler.protocol">http</stringProp>
|
||||
<stringProp name="HTTPSampler.contentEncoding"></stringProp>
|
||||
<stringProp name="HTTPSampler.path">/is1/api/auth/register</stringProp>
|
||||
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||
</HTTPSamplerProxy>
|
||||
<hashTree>
|
||||
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||
<collectionProp name="HeaderManager.headers">
|
||||
<elementProp name="" elementType="Header">
|
||||
<stringProp name="Header.name">Content-Type</stringProp>
|
||||
<stringProp name="Header.value">application/json</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</HeaderManager>
|
||||
<hashTree/>
|
||||
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract Token" enabled="true">
|
||||
<stringProp name="JSONPostProcessor.referenceNames">token</stringProp>
|
||||
<stringProp name="JSONPostProcessor.jsonPathExprs">$.token</stringProp>
|
||||
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
|
||||
<stringProp name="JSONPostProcessor.defaultValues">NOTFOUND</stringProp>
|
||||
</JSONPostProcessor>
|
||||
<hashTree/>
|
||||
</hashTree>
|
||||
</hashTree>
|
||||
|
||||
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="2. Concurrent CRUD Operations" enabled="true">
|
||||
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
|
||||
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
|
||||
<boolProp name="LoopController.continue_forever">false</boolProp>
|
||||
<stringProp name="LoopController.loops">1</stringProp>
|
||||
</elementProp>
|
||||
<stringProp name="ThreadGroup.num_threads">10</stringProp>
|
||||
<stringProp name="ThreadGroup.ramp_time">2</stringProp>
|
||||
<stringProp name="ThreadGroup.delay">5</stringProp>
|
||||
<boolProp name="ThreadGroup.scheduler">true</boolProp>
|
||||
<stringProp name="ThreadGroup.duration"></stringProp>
|
||||
</ThreadGroup>
|
||||
<hashTree>
|
||||
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Login" enabled="true">
|
||||
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
|
||||
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
|
||||
<collectionProp name="Arguments.arguments">
|
||||
<elementProp name="" elementType="HTTPArgument">
|
||||
<boolProp name="HTTPArgument.always_encode">false</boolProp>
|
||||
<stringProp name="Argument.value">{
|
||||
"username": "testuser${__threadNum}",
|
||||
"password": "password123"
|
||||
}</stringProp>
|
||||
<stringProp name="Argument.metadata">=</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</elementProp>
|
||||
<stringProp name="HTTPSampler.domain">localhost</stringProp>
|
||||
<stringProp name="HTTPSampler.port">8080</stringProp>
|
||||
<stringProp name="HTTPSampler.protocol">http</stringProp>
|
||||
<stringProp name="HTTPSampler.path">/is1/api/auth/login</stringProp>
|
||||
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||
</HTTPSamplerProxy>
|
||||
<hashTree>
|
||||
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||
<collectionProp name="HeaderManager.headers">
|
||||
<elementProp name="" elementType="Header">
|
||||
<stringProp name="Header.name">Content-Type</stringProp>
|
||||
<stringProp name="Header.value">application/json</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</HeaderManager>
|
||||
<hashTree/>
|
||||
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract Token" enabled="true">
|
||||
<stringProp name="JSONPostProcessor.referenceNames">token</stringProp>
|
||||
<stringProp name="JSONPostProcessor.jsonPathExprs">$.token</stringProp>
|
||||
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
|
||||
</JSONPostProcessor>
|
||||
<hashTree/>
|
||||
</hashTree>
|
||||
|
||||
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Create Band" enabled="true">
|
||||
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
|
||||
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
|
||||
<collectionProp name="Arguments.arguments">
|
||||
<elementProp name="" elementType="HTTPArgument">
|
||||
<boolProp name="HTTPArgument.always_encode">false</boolProp>
|
||||
<stringProp name="Argument.value">{
|
||||
"name": "Test Band ${__threadNum}_${__time(,)}",
|
||||
"genre": "ROCK",
|
||||
"coordinates": {
|
||||
"x": ${__Random(1,1000)},
|
||||
"y": ${__Random(1,1000)}
|
||||
},
|
||||
"numberOfParticipants": ${__Random(1,10)},
|
||||
"singlesCount": ${__Random(1,50)},
|
||||
"albumsCount": ${__Random(1,20)},
|
||||
"establishmentDate": "20${__Random(10,23)}-01-${__Random(10,28)}T10:00:00+03:00",
|
||||
"description": "Test band ${__threadNum}"
|
||||
}</stringProp>
|
||||
<stringProp name="Argument.metadata">=</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</elementProp>
|
||||
<stringProp name="HTTPSampler.domain">localhost</stringProp>
|
||||
<stringProp name="HTTPSampler.port">8080</stringProp>
|
||||
<stringProp name="HTTPSampler.path">/is1/api/music-bands</stringProp>
|
||||
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||
</HTTPSamplerProxy>
|
||||
<hashTree>
|
||||
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||
<collectionProp name="HeaderManager.headers">
|
||||
<elementProp name="" elementType="Header">
|
||||
<stringProp name="Header.name">Content-Type</stringProp>
|
||||
<stringProp name="Header.value">application/json</stringProp>
|
||||
</elementProp>
|
||||
<elementProp name="" elementType="Header">
|
||||
<stringProp name="Header.name">Authorization</stringProp>
|
||||
<stringProp name="Header.value">Bearer ${token}</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</HeaderManager>
|
||||
<hashTree/>
|
||||
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract Band ID" enabled="true">
|
||||
<stringProp name="JSONPostProcessor.referenceNames">bandId</stringProp>
|
||||
<stringProp name="JSONPostProcessor.jsonPathExprs">$.id</stringProp>
|
||||
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
|
||||
</JSONPostProcessor>
|
||||
<hashTree/>
|
||||
</hashTree>
|
||||
|
||||
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Update Band" enabled="true">
|
||||
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
|
||||
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
|
||||
<collectionProp name="Arguments.arguments">
|
||||
<elementProp name="" elementType="HTTPArgument">
|
||||
<boolProp name="HTTPArgument.always_encode">false</boolProp>
|
||||
<stringProp name="Argument.value">{
|
||||
"name": "Updated Band ${__threadNum}_${__time(,)}",
|
||||
"genre": "JAZZ",
|
||||
"coordinates": {
|
||||
"x": ${__Random(1,1000)},
|
||||
"y": ${__Random(1,1000)}
|
||||
},
|
||||
"numberOfParticipants": ${__Random(1,10)},
|
||||
"singlesCount": ${__Random(1,50)},
|
||||
"albumsCount": ${__Random(1,20)},
|
||||
"establishmentDate": "20${__Random(10,23)}-02-${__Random(10,28)}T10:00:00+03:00"
|
||||
}</stringProp>
|
||||
<stringProp name="Argument.metadata">=</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</elementProp>
|
||||
<stringProp name="HTTPSampler.domain">localhost</stringProp>
|
||||
<stringProp name="HTTPSampler.port">8080</stringProp>
|
||||
<stringProp name="HTTPSampler.path">/is1/api/music-bands/${bandId}</stringProp>
|
||||
<stringProp name="HTTPSampler.method">PUT</stringProp>
|
||||
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||
</HTTPSamplerProxy>
|
||||
<hashTree>
|
||||
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||
<collectionProp name="HeaderManager.headers">
|
||||
<elementProp name="" elementType="Header">
|
||||
<stringProp name="Header.name">Content-Type</stringProp>
|
||||
<stringProp name="Header.value">application/json</stringProp>
|
||||
</elementProp>
|
||||
<elementProp name="" elementType="Header">
|
||||
<stringProp name="Header.name">Authorization</stringProp>
|
||||
<stringProp name="Header.value">Bearer ${token}</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</HeaderManager>
|
||||
<hashTree/>
|
||||
</hashTree>
|
||||
|
||||
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Delete Band" enabled="true">
|
||||
<stringProp name="HTTPSampler.domain">localhost</stringProp>
|
||||
<stringProp name="HTTPSampler.port">8080</stringProp>
|
||||
<stringProp name="HTTPSampler.path">/is1/api/music-bands/${bandId}</stringProp>
|
||||
<stringProp name="HTTPSampler.method">DELETE</stringProp>
|
||||
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||
</HTTPSamplerProxy>
|
||||
<hashTree>
|
||||
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||
<collectionProp name="HeaderManager.headers">
|
||||
<elementProp name="" elementType="Header">
|
||||
<stringProp name="Header.name">Authorization</stringProp>
|
||||
<stringProp name="Header.value">Bearer ${token}</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</HeaderManager>
|
||||
<hashTree/>
|
||||
</hashTree>
|
||||
</hashTree>
|
||||
|
||||
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="3. Unique Constraint Test" enabled="true">
|
||||
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
|
||||
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
|
||||
<boolProp name="LoopController.continue_forever">false</boolProp>
|
||||
<stringProp name="LoopController.loops">1</stringProp>
|
||||
</elementProp>
|
||||
<stringProp name="ThreadGroup.num_threads">5</stringProp>
|
||||
<stringProp name="ThreadGroup.ramp_time">0</stringProp>
|
||||
<stringProp name="ThreadGroup.delay">10</stringProp>
|
||||
<boolProp name="ThreadGroup.scheduler">true</boolProp>
|
||||
</ThreadGroup>
|
||||
<hashTree>
|
||||
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Login Admin" enabled="true">
|
||||
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
|
||||
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
|
||||
<collectionProp name="Arguments.arguments">
|
||||
<elementProp name="" elementType="HTTPArgument">
|
||||
<boolProp name="HTTPArgument.always_encode">false</boolProp>
|
||||
<stringProp name="Argument.value">{
|
||||
"username": "admin",
|
||||
"password": "admin"
|
||||
}</stringProp>
|
||||
<stringProp name="Argument.metadata">=</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</elementProp>
|
||||
<stringProp name="HTTPSampler.domain">localhost</stringProp>
|
||||
<stringProp name="HTTPSampler.port">8080</stringProp>
|
||||
<stringProp name="HTTPSampler.path">/is1/api/auth/login</stringProp>
|
||||
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||
</HTTPSamplerProxy>
|
||||
<hashTree>
|
||||
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||
<collectionProp name="HeaderManager.headers">
|
||||
<elementProp name="" elementType="Header">
|
||||
<stringProp name="Header.name">Content-Type</stringProp>
|
||||
<stringProp name="Header.value">application/json</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</HeaderManager>
|
||||
<hashTree/>
|
||||
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract Token" enabled="true">
|
||||
<stringProp name="JSONPostProcessor.referenceNames">token</stringProp>
|
||||
<stringProp name="JSONPostProcessor.jsonPathExprs">$.token</stringProp>
|
||||
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
|
||||
</JSONPostProcessor>
|
||||
<hashTree/>
|
||||
</hashTree>
|
||||
|
||||
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Create Band with Same Unique Fields" enabled="true">
|
||||
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
|
||||
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
|
||||
<collectionProp name="Arguments.arguments">
|
||||
<elementProp name="" elementType="HTTPArgument">
|
||||
<boolProp name="HTTPArgument.always_encode">false</boolProp>
|
||||
<stringProp name="Argument.value">{
|
||||
"name": "Unique Test Band",
|
||||
"genre": "ROCK",
|
||||
"coordinates": {"x": 100, "y": 100},
|
||||
"numberOfParticipants": 5,
|
||||
"singlesCount": 10,
|
||||
"establishmentDate": "2023-01-01T10:00:00+03:00"
|
||||
}</stringProp>
|
||||
<stringProp name="Argument.metadata">=</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</elementProp>
|
||||
<stringProp name="HTTPSampler.domain">localhost</stringProp>
|
||||
<stringProp name="HTTPSampler.port">8080</stringProp>
|
||||
<stringProp name="HTTPSampler.path">/is1/api/music-bands</stringProp>
|
||||
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||
</HTTPSamplerProxy>
|
||||
<hashTree>
|
||||
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||
<collectionProp name="HeaderManager.headers">
|
||||
<elementProp name="" elementType="Header">
|
||||
<stringProp name="Header.name">Content-Type</stringProp>
|
||||
<stringProp name="Header.value">application/json</stringProp>
|
||||
</elementProp>
|
||||
<elementProp name="" elementType="Header">
|
||||
<stringProp name="Header.name">Authorization</stringProp>
|
||||
<stringProp name="Header.value">Bearer ${token}</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</HeaderManager>
|
||||
<hashTree/>
|
||||
</hashTree>
|
||||
</hashTree>
|
||||
|
||||
<ResultCollector guiclass="SummaryReport" testclass="ResultCollector" testname="Summary Report" enabled="true">
|
||||
<boolProp name="ResultCollector.error_logging">false</boolProp>
|
||||
<objProp>
|
||||
<name>saveConfig</name>
|
||||
<value class="SampleSaveConfiguration">
|
||||
<time>true</time>
|
||||
<latency>true</latency>
|
||||
<timestamp>true</timestamp>
|
||||
<success>true</success>
|
||||
<label>true</label>
|
||||
<code>true</code>
|
||||
<message>true</message>
|
||||
<threadName>true</threadName>
|
||||
<dataType>true</dataType>
|
||||
<encoding>false</encoding>
|
||||
<assertions>true</assertions>
|
||||
<subresults>true</subresults>
|
||||
<responseData>false</responseData>
|
||||
<samplerData>false</samplerData>
|
||||
<xml>false</xml>
|
||||
<fieldNames>true</fieldNames>
|
||||
<responseHeaders>false</responseHeaders>
|
||||
<requestHeaders>false</requestHeaders>
|
||||
<responseDataOnError>false</responseDataOnError>
|
||||
<saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
|
||||
<assertionsResultsToSave>0</assertionsResultsToSave>
|
||||
<bytes>true</bytes>
|
||||
<sentBytes>true</sentBytes>
|
||||
<url>true</url>
|
||||
<threadCounts>true</threadCounts>
|
||||
<idleTime>true</idleTime>
|
||||
<connectTime>true</connectTime>
|
||||
</value>
|
||||
</objProp>
|
||||
<stringProp name="filename"></stringProp>
|
||||
</ResultCollector>
|
||||
<hashTree/>
|
||||
</hashTree>
|
||||
</hashTree>
|
||||
</jmeterTestPlan>
|
||||
57
bands.json
Normal file
57
bands.json
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"bands": [
|
||||
{
|
||||
"name": "The Rolling Stones",
|
||||
"genre": "ROCK",
|
||||
"coordinates": {
|
||||
"x": 100,
|
||||
"y": 200
|
||||
},
|
||||
"numberOfParticipants": 4,
|
||||
"singlesCount": 50,
|
||||
"albumsCount": 30,
|
||||
"establishmentDate": "1962-07-12T10:00:00+03:00",
|
||||
"description": "British rock band formed in London",
|
||||
"bestAlbum": {
|
||||
"name": "Sticky Fingers",
|
||||
"tracks": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Pink Floyd",
|
||||
"genre": "PSYCHEDELIC_CLOUD_RAP",
|
||||
"coordinates": {
|
||||
"x": 150,
|
||||
"y": 250
|
||||
},
|
||||
"numberOfParticipants": 5,
|
||||
"singlesCount": 30,
|
||||
"albumsCount": 15,
|
||||
"establishmentDate": "1965-01-01T12:00:00+03:00",
|
||||
"description": "Progressive rock band",
|
||||
"bestAlbum": {
|
||||
"name": "The Dark Side of the Moon",
|
||||
"tracks": 10
|
||||
},
|
||||
"frontMan": {
|
||||
"name": "David Gilmour",
|
||||
"eyeColor": "BLUE",
|
||||
"hairColor": "BROWN",
|
||||
"height": 183.0,
|
||||
"nationality": "SOUTH_KOREA",
|
||||
"location": {
|
||||
"x": 10,
|
||||
"y": 20.5,
|
||||
"z": 30
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Miles Davis Quintet",
|
||||
"genre": "JAZZ",
|
||||
"coordinates": {
|
||||
"x": 300,
|
||||
"y": 400
|
||||
},
|
||||
"numberOfParticipants": 5,
|
||||
"singlesCount": 20,
|
||||
46
bands.xml
Normal file
46
bands.xml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MusicBandsWrapper>
|
||||
<bands>
|
||||
<MusicBand>
|
||||
<name>Queen</name>
|
||||
<genre>ROCK</genre>
|
||||
<coordinates>
|
||||
<x>500</x>
|
||||
<y>600</y>
|
||||
</coordinates>
|
||||
<numberOfParticipants>4</numberOfParticipants>
|
||||
<singlesCount>40</singlesCount>
|
||||
<albumsCount>15</albumsCount>
|
||||
<establishmentDate>1970-06-27T10:00:00+03:00</establishmentDate>
|
||||
<description>British rock band</description>
|
||||
<bestAlbum>
|
||||
<name>A Night at the Opera</name>
|
||||
<tracks>12</tracks>
|
||||
</bestAlbum>
|
||||
<frontMan>
|
||||
<name>Freddie Mercury</name>
|
||||
<eyeColor>BROWN</eyeColor>
|
||||
<hairColor>BROWN</hairColor>
|
||||
<height>177.0</height>
|
||||
<nationality>THAILAND</nationality>
|
||||
</frontMan>
|
||||
</MusicBand>
|
||||
<MusicBand>
|
||||
<name>Led Zeppelin</name>
|
||||
<genre>ROCK</genre>
|
||||
<coordinates>
|
||||
<x>700</x>
|
||||
<y>800</y>
|
||||
</coordinates>
|
||||
<numberOfParticipants>4</numberOfParticipants>
|
||||
<singlesCount>25</singlesCount>
|
||||
<albumsCount>9</albumsCount>
|
||||
<establishmentDate>1968-09-07T11:00:00+03:00</establishmentDate>
|
||||
<description>English rock band</description>
|
||||
<bestAlbum>
|
||||
<name>Led Zeppelin IV</name>
|
||||
<tracks>8</tracks>
|
||||
</bestAlbum>
|
||||
</MusicBand>
|
||||
</bands>
|
||||
</MusicBandsWrapper>
|
||||
57
bands.yaml
Normal file
57
bands.yaml
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
bands:
|
||||
- name: "Nirvana"
|
||||
genre: ROCK
|
||||
coordinates:
|
||||
x: 900
|
||||
y: 1000
|
||||
numberOfParticipants: 3
|
||||
singlesCount: 15
|
||||
albumsCount: 3
|
||||
establishmentDate: "1987-01-01T09:00:00+03:00"
|
||||
description: "American grunge band"
|
||||
bestAlbum:
|
||||
name: "Nevermind"
|
||||
tracks: 12
|
||||
frontMan:
|
||||
name: "Kurt Cobain"
|
||||
eyeColor: BLUE
|
||||
height: 175.0
|
||||
nationality: NORTH_KOREA
|
||||
|
||||
- name: "The Beatles"
|
||||
genre: ROCK
|
||||
coordinates:
|
||||
x: 1100
|
||||
y: 1200
|
||||
numberOfParticipants: 4
|
||||
singlesCount: 63
|
||||
albumsCount: 13
|
||||
establishmentDate: "1960-08-18T10:30:00+03:00"
|
||||
description: "English rock band"
|
||||
bestAlbum:
|
||||
name: "Abbey Road"
|
||||
tracks: 17
|
||||
frontMan:
|
||||
name: "John Lennon"
|
||||
eyeColor: BROWN
|
||||
hairColor: BROWN
|
||||
height: 179.0
|
||||
nationality: INDIA
|
||||
location:
|
||||
x: 5
|
||||
y: 10.0
|
||||
z: 15
|
||||
|
||||
- name: "Coltrane Quartet"
|
||||
genre: JAZZ
|
||||
coordinates:
|
||||
x: 1300
|
||||
y: 1400
|
||||
numberOfParticipants: 4
|
||||
singlesCount: 12
|
||||
albumsCount: 20
|
||||
establishmentDate: "1960-05-01T13:00:00+03:00"
|
||||
description: "Jazz quartet"
|
||||
bestAlbum:
|
||||
name: "A Love Supreme"
|
||||
tracks: 4
|
||||
82
build.sh
82
build.sh
|
|
@ -1,35 +1,53 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Build script for Music Band Information System
|
||||
|
||||
echo "Building Music Band Information System..."
|
||||
|
||||
# Clean previous builds
|
||||
echo "Cleaning previous builds..."
|
||||
mvn clean
|
||||
|
||||
# Compile and package
|
||||
echo "Compiling and packaging..."
|
||||
mvn package -DskipTests
|
||||
|
||||
# Check if build was successful
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Build successful! WAR file created at target/is1.war"
|
||||
echo "File size: $(ls -lh target/is1.war | awk '{print $5}')"
|
||||
|
||||
# Copy to WildFly deployments directory
|
||||
echo ""
|
||||
echo "Deploying to WildFly..."
|
||||
cp target/is1.war wildfly/standalone/deployments/
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "WAR file successfully copied to wildfly/standalone/deployments/"
|
||||
echo "Application will be auto-deployed when WildFly starts."
|
||||
else
|
||||
echo "Failed to copy WAR file to WildFly deployments directory"
|
||||
echo "Make sure wildfly/standalone/deployments/ directory exists"
|
||||
fi
|
||||
else
|
||||
echo "Build failed!"
|
||||
set -e
|
||||
if [ ! -f "pom.xml" ]; then
|
||||
echo "Error: pom.xml not found. Please run this script from the project root."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[1/5] Cleaning previous builds..."
|
||||
mvn clean
|
||||
echo "Clean completed"
|
||||
echo ""
|
||||
|
||||
echo "[2/5] Compiling sources..."
|
||||
mvn compile
|
||||
echo "Compilation completed"
|
||||
echo ""
|
||||
|
||||
echo "[3/5] Running tests..."
|
||||
mvn test -DskipTests
|
||||
echo "Tests completed (skipped)"
|
||||
echo ""
|
||||
|
||||
echo "[4/5] Packaging WAR file..."
|
||||
mvn package -DskipTests
|
||||
echo "Packaging completed"
|
||||
echo ""
|
||||
|
||||
if [ ! -f "target/is1.war" ]; then
|
||||
echo "Error: WAR file not created. Build failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WAR_SIZE=$(ls -lh target/is1.war | awk '{print $5}')
|
||||
echo "WAR file created: target/is1.war (${WAR_SIZE})"
|
||||
echo ""
|
||||
|
||||
echo "[5/5] Deploying to WildFly..."
|
||||
|
||||
WILDFLY_DEPLOYMENTS="wildfly/standalone/deployments"
|
||||
|
||||
if [ -d "$WILDFLY_DEPLOYMENTS" ]; then
|
||||
cp target/is1.war "$WILDFLY_DEPLOYMENTS/"
|
||||
echo "WAR file copied to: $WILDFLY_DEPLOYMENTS/is1.war"
|
||||
else
|
||||
echo "WildFly deployments directory not found at: $WILDFLY_DEPLOYMENTS"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✓ Build successful"
|
||||
echo "✓ WAR file: target/is1.war (${WAR_SIZE})"
|
||||
echo "✓ Ready for deployment"
|
||||
echo ""
|
||||
42
pom.xml
42
pom.xml
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
<groupId>ru.akarpov</groupId>
|
||||
<artifactId>is1</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<version>2.0.0</version>
|
||||
<packaging>war</packaging>
|
||||
|
||||
<properties>
|
||||
|
|
@ -49,6 +49,44 @@
|
|||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
<version>2.15.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||
<artifactId>jackson-dataformat-xml</artifactId>
|
||||
<version>2.15.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||
<artifactId>jackson-dataformat-yaml</artifactId>
|
||||
<version>2.15.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.11.5</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.mindrot</groupId>
|
||||
<artifactId>jbcrypt</artifactId>
|
||||
<version>0.4</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
@ -73,4 +111,4 @@
|
|||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
</project>
|
||||
52
schema.sql
52
schema.sql
|
|
@ -1,13 +1,11 @@
|
|||
-- Database schema for Music Band Information System
|
||||
|
||||
-- Create sequences for auto-generated IDs
|
||||
CREATE SEQUENCE IF NOT EXISTS coordinates_seq START 1;
|
||||
CREATE SEQUENCE IF NOT EXISTS location_seq START 1;
|
||||
CREATE SEQUENCE IF NOT EXISTS album_seq START 1;
|
||||
CREATE SEQUENCE IF NOT EXISTS person_seq START 1;
|
||||
CREATE SEQUENCE IF NOT EXISTS music_band_seq START 1;
|
||||
CREATE SEQUENCE IF NOT EXISTS users_seq START 1;
|
||||
CREATE SEQUENCE IF NOT EXISTS import_operation_seq START 1;
|
||||
|
||||
-- Create tables
|
||||
CREATE TABLE IF NOT EXISTS coordinates (
|
||||
id BIGINT PRIMARY KEY DEFAULT nextval('coordinates_seq'),
|
||||
x BIGINT NOT NULL,
|
||||
|
|
@ -37,6 +35,23 @@ CREATE TABLE IF NOT EXISTS person (
|
|||
nationality VARCHAR(20) NOT NULL CHECK (nationality IN ('INDIA', 'THAILAND', 'SOUTH_KOREA', 'NORTH_KOREA'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT PRIMARY KEY DEFAULT nextval('users_seq'),
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('USER', 'ADMIN')),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_operation (
|
||||
id BIGINT PRIMARY KEY DEFAULT nextval('import_operation_seq'),
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('SUCCESS', 'FAILED')),
|
||||
objects_count INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS music_band (
|
||||
id BIGINT PRIMARY KEY DEFAULT nextval('music_band_seq'),
|
||||
name VARCHAR(255) NOT NULL CHECK (name <> ''),
|
||||
|
|
@ -49,16 +64,33 @@ CREATE TABLE IF NOT EXISTS music_band (
|
|||
best_album_id BIGINT REFERENCES album(id) ON DELETE SET NULL,
|
||||
albums_count INTEGER CHECK (albums_count IS NULL OR albums_count > 0),
|
||||
establishment_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
front_man_id BIGINT REFERENCES person(id) ON DELETE SET NULL
|
||||
front_man_id BIGINT REFERENCES person(id) ON DELETE SET NULL,
|
||||
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'music_band'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM music_band WHERE created_by IS NOT NULL LIMIT 1
|
||||
) THEN
|
||||
UPDATE music_band
|
||||
SET created_by = (SELECT id FROM users WHERE username = 'admin' LIMIT 1)
|
||||
WHERE created_by IS NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_music_band_name ON music_band(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_music_band_genre ON music_band(genre);
|
||||
CREATE INDEX IF NOT EXISTS idx_music_band_participants ON music_band(number_of_participants);
|
||||
CREATE INDEX IF NOT EXISTS idx_music_band_establishment_date ON music_band(establishment_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_music_band_created_by ON music_band(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_import_operation_user ON import_operation(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_import_operation_status ON import_operation(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
-- Database functions for special operations
|
||||
CREATE OR REPLACE FUNCTION calculate_average_albums_count()
|
||||
RETURNS DOUBLE PRECISION AS $$
|
||||
BEGIN
|
||||
|
|
@ -112,4 +144,8 @@ BEGIN
|
|||
RAISE EXCEPTION 'Band with ID % not found or cannot remove participant (minimum 1 required)', band_id;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
INSERT INTO users (username, password_hash, role)
|
||||
VALUES ('admin', '$2a$10$SIVWLONFduZBQmozzHmVbO21zvCfXeg649BvXLwbYxL/8EOBGCqSG', 'ADMIN')
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
49
src/main/java/ru/akarpov/is1/dto/AuthResponse.java
Normal file
49
src/main/java/ru/akarpov/is1/dto/AuthResponse.java
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package ru.akarpov.is1.dto;
|
||||
|
||||
public class AuthResponse {
|
||||
private String token;
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String role;
|
||||
|
||||
public AuthResponse() {}
|
||||
|
||||
public AuthResponse(String token, Long userId, String username, String role) {
|
||||
this.token = token;
|
||||
this.userId = userId;
|
||||
this.username = username;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(String role) {
|
||||
this.role = role;
|
||||
}
|
||||
}
|
||||
42
src/main/java/ru/akarpov/is1/dto/ImportHistoryResponse.java
Normal file
42
src/main/java/ru/akarpov/is1/dto/ImportHistoryResponse.java
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package ru.akarpov.is1.dto;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public class ImportHistoryResponse {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String status;
|
||||
private Integer objectsCount;
|
||||
private String errorMessage;
|
||||
private Date createdAt;
|
||||
|
||||
public ImportHistoryResponse() {}
|
||||
|
||||
public ImportHistoryResponse(Long id, String username, String status, Integer objectsCount,
|
||||
String errorMessage, Date createdAt) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.status = status;
|
||||
this.objectsCount = objectsCount;
|
||||
this.errorMessage = errorMessage;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
|
||||
public Integer getObjectsCount() { return objectsCount; }
|
||||
public void setObjectsCount(Integer objectsCount) { this.objectsCount = objectsCount; }
|
||||
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||
|
||||
public Date getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
27
src/main/java/ru/akarpov/is1/dto/LoginRequest.java
Normal file
27
src/main/java/ru/akarpov/is1/dto/LoginRequest.java
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package ru.akarpov.is1.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class LoginRequest {
|
||||
@NotBlank
|
||||
private String username;
|
||||
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
30
src/main/java/ru/akarpov/is1/dto/RegisterRequest.java
Normal file
30
src/main/java/ru/akarpov/is1/dto/RegisterRequest.java
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package ru.akarpov.is1.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public class RegisterRequest {
|
||||
@NotBlank
|
||||
@Size(min = 3, max = 50)
|
||||
private String username;
|
||||
|
||||
@NotBlank
|
||||
@Size(min = 6)
|
||||
private String password;
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
69
src/main/java/ru/akarpov/is1/entity/ImportOperation.java
Normal file
69
src/main/java/ru/akarpov/is1/entity/ImportOperation.java
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package ru.akarpov.is1.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.Date;
|
||||
|
||||
@Entity
|
||||
@Table(name = "import_operation")
|
||||
@NamedQueries({
|
||||
@NamedQuery(name = "ImportOperation.findByUser",
|
||||
query = "SELECT io FROM ImportOperation io WHERE io.userId = :userId ORDER BY io.createdAt DESC"),
|
||||
@NamedQuery(name = "ImportOperation.findAll",
|
||||
query = "SELECT io FROM ImportOperation io ORDER BY io.createdAt DESC")
|
||||
})
|
||||
public class ImportOperation {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "import_operation_seq_gen")
|
||||
@SequenceGenerator(name = "import_operation_seq_gen", sequenceName = "import_operation_seq", allocationSize = 1)
|
||||
private Long id;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false)
|
||||
private ImportStatus status;
|
||||
|
||||
@Column(name = "objects_count")
|
||||
private Integer objectsCount;
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
private String errorMessage;
|
||||
|
||||
@NotNull
|
||||
@Temporal(TemporalType.TIMESTAMP)
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Date createdAt;
|
||||
|
||||
public ImportOperation() {
|
||||
this.createdAt = new Date();
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Long getUserId() { return userId; }
|
||||
public void setUserId(Long userId) { this.userId = userId; }
|
||||
|
||||
public ImportStatus getStatus() { return status; }
|
||||
public void setStatus(ImportStatus status) { this.status = status; }
|
||||
|
||||
public Integer getObjectsCount() { return objectsCount; }
|
||||
public void setObjectsCount(Integer objectsCount) { this.objectsCount = objectsCount; }
|
||||
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||
|
||||
public Date getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
6
src/main/java/ru/akarpov/is1/entity/ImportStatus.java
Normal file
6
src/main/java/ru/akarpov/is1/entity/ImportStatus.java
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package ru.akarpov.is1.entity;
|
||||
|
||||
public enum ImportStatus {
|
||||
SUCCESS,
|
||||
FAILED
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ import jakarta.validation.constraints.NotNull;
|
|||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Date;
|
||||
|
||||
@Entity
|
||||
|
|
@ -20,7 +19,11 @@ import java.util.Date;
|
|||
@NamedQuery(name = "MusicBand.findWithMaxName",
|
||||
query = "SELECT mb FROM MusicBand mb WHERE mb.name = (SELECT MAX(mb2.name) FROM MusicBand mb2)"),
|
||||
@NamedQuery(name = "MusicBand.groupByParticipants",
|
||||
query = "SELECT mb.numberOfParticipants, COUNT(mb) FROM MusicBand mb GROUP BY mb.numberOfParticipants")
|
||||
query = "SELECT mb.numberOfParticipants, COUNT(mb) FROM MusicBand mb GROUP BY mb.numberOfParticipants"),
|
||||
@NamedQuery(name = "MusicBand.checkNameGenreUnique",
|
||||
query = "SELECT COUNT(mb) FROM MusicBand mb WHERE mb.name = :name AND mb.genre = :genre AND mb.id <> :excludeId"),
|
||||
@NamedQuery(name = "MusicBand.checkEstablishmentDateUnique",
|
||||
query = "SELECT COUNT(mb) FROM MusicBand mb WHERE mb.establishmentDate = :establishmentDate AND mb.id <> :excludeId")
|
||||
})
|
||||
public class MusicBand {
|
||||
|
||||
|
|
@ -70,17 +73,18 @@ public class MusicBand {
|
|||
@Column(name = "albums_count")
|
||||
private Integer albumsCount;
|
||||
|
||||
|
||||
@NotNull
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
@Column(name = "establishment_date", nullable = false)
|
||||
private OffsetDateTime establishmentDate;
|
||||
|
||||
|
||||
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
@JoinColumn(name = "front_man_id")
|
||||
private Person frontMan;
|
||||
|
||||
@Column(name = "created_by")
|
||||
private Long createdBy;
|
||||
|
||||
public MusicBand() {
|
||||
this.creationDate = new Date();
|
||||
}
|
||||
|
|
@ -125,7 +129,9 @@ public class MusicBand {
|
|||
public OffsetDateTime getEstablishmentDate() { return establishmentDate; }
|
||||
public void setEstablishmentDate(OffsetDateTime establishmentDate) { this.establishmentDate = establishmentDate; }
|
||||
|
||||
|
||||
public Person getFrontMan() { return frontMan; }
|
||||
public void setFrontMan(Person frontMan) { this.frontMan = frontMan; }
|
||||
}
|
||||
|
||||
public Long getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(Long createdBy) { this.createdBy = createdBy; }
|
||||
}
|
||||
6
src/main/java/ru/akarpov/is1/entity/Role.java
Normal file
6
src/main/java/ru/akarpov/is1/entity/Role.java
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package ru.akarpov.is1.entity;
|
||||
|
||||
public enum Role {
|
||||
USER,
|
||||
ADMIN
|
||||
}
|
||||
63
src/main/java/ru/akarpov/is1/entity/User.java
Normal file
63
src/main/java/ru/akarpov/is1/entity/User.java
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package ru.akarpov.is1.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.Date;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "users_seq_gen")
|
||||
@SequenceGenerator(name = "users_seq_gen", sequenceName = "users_seq", allocationSize = 1)
|
||||
private Long id;
|
||||
|
||||
@NotNull
|
||||
@NotBlank
|
||||
@Size(min = 3, max = 50)
|
||||
@Column(name = "username", unique = true, nullable = false, length = 50)
|
||||
private String username;
|
||||
|
||||
@NotNull
|
||||
@NotBlank
|
||||
@Column(name = "password_hash", nullable = false)
|
||||
private String passwordHash;
|
||||
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "role", nullable = false)
|
||||
private Role role;
|
||||
|
||||
@NotNull
|
||||
@Temporal(TemporalType.TIMESTAMP)
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Date createdAt;
|
||||
|
||||
public User() {
|
||||
this.createdAt = new Date();
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
|
||||
public String getPasswordHash() { return passwordHash; }
|
||||
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
|
||||
|
||||
public Role getRole() { return role; }
|
||||
public void setRole(Role role) { this.role = role; }
|
||||
|
||||
public Date getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package ru.akarpov.is1.repository;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import ru.akarpov.is1.entity.ImportOperation;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ApplicationScoped
|
||||
public class ImportOperationRepository {
|
||||
|
||||
@PersistenceContext(unitName = "musicBandPU")
|
||||
private EntityManager em;
|
||||
|
||||
public ImportOperation save(ImportOperation operation) {
|
||||
if (operation.getId() == null) {
|
||||
em.persist(operation);
|
||||
em.flush();
|
||||
return operation;
|
||||
} else {
|
||||
ImportOperation merged = em.merge(operation);
|
||||
em.flush();
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
public List<ImportOperation> findByUser(Long userId) {
|
||||
return em.createNamedQuery("ImportOperation.findByUser", ImportOperation.class)
|
||||
.setParameter("userId", userId)
|
||||
.getResultList();
|
||||
}
|
||||
|
||||
public List<ImportOperation> findAll() {
|
||||
return em.createNamedQuery("ImportOperation.findAll", ImportOperation.class)
|
||||
.getResultList();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package ru.akarpov.is1.repository;
|
||||
|
||||
import ru.akarpov.is1.entity.MusicBand;
|
||||
import ru.akarpov.is1.entity.MusicGenre;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
|
@ -13,4 +16,6 @@ public interface MusicBandRepository extends GenericRepository<MusicBand, Long>
|
|||
Map<Long, Long> groupByNumberOfParticipants();
|
||||
MusicBand addSingle(Long bandId);
|
||||
MusicBand removeParticipant(Long bandId);
|
||||
Long checkNameGenreUnique(String name, MusicGenre genre, Long excludeId);
|
||||
Long checkEstablishmentDateUnique(OffsetDateTime establishmentDate, Long excludeId);
|
||||
}
|
||||
|
|
@ -2,9 +2,12 @@ package ru.akarpov.is1.repository;
|
|||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import ru.akarpov.is1.entity.MusicBand;
|
||||
import ru.akarpov.is1.entity.MusicGenre;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
|
@ -16,16 +19,19 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
|
|||
|
||||
private static final Logger logger = Logger.getLogger(MusicBandRepositoryImpl.class.getName());
|
||||
|
||||
@PersistenceContext
|
||||
@PersistenceContext(unitName = "musicBandPU")
|
||||
private EntityManager em;
|
||||
|
||||
@Override
|
||||
public MusicBand save(MusicBand entity) {
|
||||
if (entity.getId() == 0) {
|
||||
em.persist(entity);
|
||||
em.flush();
|
||||
return entity;
|
||||
} else {
|
||||
return em.merge(entity);
|
||||
MusicBand merged = em.merge(entity);
|
||||
em.flush();
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +66,8 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
|
|||
} else {
|
||||
em.remove(em.merge(entity));
|
||||
}
|
||||
logger.info("MusicBand marked for deletion");
|
||||
em.flush();
|
||||
logger.info("MusicBand deleted");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -69,7 +76,8 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
|
|||
MusicBand band = em.find(MusicBand.class, id);
|
||||
if (band != null) {
|
||||
em.remove(band);
|
||||
logger.info("MusicBand with ID " + id + " marked for deletion");
|
||||
em.flush();
|
||||
logger.info("MusicBand with ID " + id + " deleted");
|
||||
} else {
|
||||
logger.warning("MusicBand with ID " + id + " not found for deletion");
|
||||
}
|
||||
|
|
@ -156,7 +164,7 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
|
|||
@Override
|
||||
public MusicBand addSingle(Long bandId) {
|
||||
logger.info("Adding single to band ID: " + bandId);
|
||||
MusicBand band = em.find(MusicBand.class, bandId);
|
||||
MusicBand band = em.find(MusicBand.class, bandId, LockModeType.PESSIMISTIC_WRITE);
|
||||
if (band == null) {
|
||||
logger.warning("Band not found with ID: " + bandId);
|
||||
return null;
|
||||
|
|
@ -165,6 +173,7 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
|
|||
int oldCount = band.getSinglesCount();
|
||||
band.setSinglesCount(oldCount + 1);
|
||||
MusicBand merged = em.merge(band);
|
||||
em.flush();
|
||||
|
||||
logger.info("Single added to band ID " + bandId + ": " + oldCount + " -> " + merged.getSinglesCount());
|
||||
return merged;
|
||||
|
|
@ -173,7 +182,7 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
|
|||
@Override
|
||||
public MusicBand removeParticipant(Long bandId) {
|
||||
logger.info("Removing participant from band ID: " + bandId);
|
||||
MusicBand band = em.find(MusicBand.class, bandId);
|
||||
MusicBand band = em.find(MusicBand.class, bandId, LockModeType.PESSIMISTIC_WRITE);
|
||||
if (band == null || band.getNumberOfParticipants() <= 1) {
|
||||
if (band == null) {
|
||||
logger.warning("Band not found with ID: " + bandId);
|
||||
|
|
@ -186,8 +195,26 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
|
|||
long oldCount = band.getNumberOfParticipants();
|
||||
band.setNumberOfParticipants(oldCount - 1);
|
||||
MusicBand merged = em.merge(band);
|
||||
em.flush();
|
||||
|
||||
logger.info("Participant removed from band ID " + bandId + ": " + oldCount + " -> " + merged.getNumberOfParticipants());
|
||||
return merged;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long checkNameGenreUnique(String name, MusicGenre genre, Long excludeId) {
|
||||
return em.createNamedQuery("MusicBand.checkNameGenreUnique", Long.class)
|
||||
.setParameter("name", name)
|
||||
.setParameter("genre", genre)
|
||||
.setParameter("excludeId", excludeId)
|
||||
.getSingleResult();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long checkEstablishmentDateUnique(OffsetDateTime establishmentDate, Long excludeId) {
|
||||
return em.createNamedQuery("MusicBand.checkEstablishmentDateUnique", Long.class)
|
||||
.setParameter("establishmentDate", establishmentDate)
|
||||
.setParameter("excludeId", excludeId)
|
||||
.getSingleResult();
|
||||
}
|
||||
}
|
||||
51
src/main/java/ru/akarpov/is1/repository/UserRepository.java
Normal file
51
src/main/java/ru/akarpov/is1/repository/UserRepository.java
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package ru.akarpov.is1.repository;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.NoResultException;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import ru.akarpov.is1.entity.User;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@ApplicationScoped
|
||||
public class UserRepository {
|
||||
|
||||
@PersistenceContext(unitName = "musicBandPU")
|
||||
private EntityManager em;
|
||||
|
||||
public User save(User user) {
|
||||
if (user.getId() == null) {
|
||||
em.persist(user);
|
||||
em.flush();
|
||||
return user;
|
||||
} else {
|
||||
User merged = em.merge(user);
|
||||
em.flush();
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<User> findById(Long id) {
|
||||
User user = em.find(User.class, id);
|
||||
return Optional.ofNullable(user);
|
||||
}
|
||||
|
||||
public Optional<User> findByUsername(String username) {
|
||||
try {
|
||||
User user = em.createQuery("SELECT u FROM User u WHERE u.username = :username", User.class)
|
||||
.setParameter("username", username)
|
||||
.getSingleResult();
|
||||
return Optional.of(user);
|
||||
} catch (NoResultException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean existsByUsername(String username) {
|
||||
Long count = em.createQuery("SELECT COUNT(u) FROM User u WHERE u.username = :username", Long.class)
|
||||
.setParameter("username", username)
|
||||
.getSingleResult();
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
55
src/main/java/ru/akarpov/is1/rest/AuthResource.java
Normal file
55
src/main/java/ru/akarpov/is1/rest/AuthResource.java
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package ru.akarpov.is1.rest;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import ru.akarpov.is1.dto.AuthResponse;
|
||||
import ru.akarpov.is1.dto.LoginRequest;
|
||||
import ru.akarpov.is1.dto.RegisterRequest;
|
||||
import ru.akarpov.is1.service.AuthService;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Path("/auth")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class AuthResource {
|
||||
|
||||
@Inject
|
||||
private AuthService authService;
|
||||
|
||||
@POST
|
||||
@Path("/register")
|
||||
public Response register(RegisterRequest request) {
|
||||
try {
|
||||
AuthResponse response = authService.register(request);
|
||||
return Response.status(Response.Status.CREATED).entity(response).build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", "Registration failed: " + e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/login")
|
||||
public Response login(LoginRequest request) {
|
||||
try {
|
||||
AuthResponse response = authService.login(request);
|
||||
return Response.ok(response).build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(Response.Status.UNAUTHORIZED)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", "Login failed: " + e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
96
src/main/java/ru/akarpov/is1/rest/ImportResource.java
Normal file
96
src/main/java/ru/akarpov/is1/rest/ImportResource.java
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package ru.akarpov.is1.rest;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import ru.akarpov.is1.dto.ImportHistoryResponse;
|
||||
import ru.akarpov.is1.entity.ImportOperation;
|
||||
import ru.akarpov.is1.security.UserPrincipal;
|
||||
import ru.akarpov.is1.service.ImportService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Path("/import")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class ImportResource {
|
||||
|
||||
@Inject
|
||||
private ImportService importService;
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.TEXT_PLAIN)
|
||||
public Response importBands(@Context HttpServletRequest request,
|
||||
@QueryParam("format") String format,
|
||||
String fileContent) {
|
||||
try {
|
||||
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
|
||||
if (principal == null) {
|
||||
return Response.status(Response.Status.UNAUTHORIZED)
|
||||
.entity(Map.of("error", "Authentication required"))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (format == null || format.trim().isEmpty()) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", "Format parameter is required"))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (fileContent == null || fileContent.trim().isEmpty()) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", "File content is required"))
|
||||
.build();
|
||||
}
|
||||
|
||||
ImportOperation operation = importService.importBands(fileContent, format, principal.getUserId());
|
||||
|
||||
if (operation.getStatus().name().equals("SUCCESS")) {
|
||||
return Response.ok(Map.of(
|
||||
"message", "Import completed successfully",
|
||||
"operationId", operation.getId(),
|
||||
"objectsCount", operation.getObjectsCount()
|
||||
)).build();
|
||||
} else {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of(
|
||||
"error", operation.getErrorMessage(),
|
||||
"operationId", operation.getId()
|
||||
))
|
||||
.build();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", "Import processing failed: " + e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/history")
|
||||
public Response getImportHistory(@Context HttpServletRequest request) {
|
||||
try {
|
||||
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
|
||||
if (principal == null) {
|
||||
return Response.status(Response.Status.UNAUTHORIZED)
|
||||
.entity(Map.of("error", "Authentication required"))
|
||||
.build();
|
||||
}
|
||||
|
||||
List<ImportHistoryResponse> history = importService.getImportHistory(
|
||||
principal.getUserId(),
|
||||
principal.isAdmin()
|
||||
);
|
||||
|
||||
return Response.ok(history).build();
|
||||
} catch (Exception e) {
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", "Failed to retrieve history: " + e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
package ru.akarpov.is1.rest;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import ru.akarpov.is1.entity.MusicBand;
|
||||
import ru.akarpov.is1.security.UserPrincipal;
|
||||
import ru.akarpov.is1.service.MusicBandService;
|
||||
|
||||
import java.util.List;
|
||||
|
|
@ -70,11 +73,19 @@ public class MusicBandResource {
|
|||
}
|
||||
|
||||
@POST
|
||||
public Response createBand(MusicBand musicBand) {
|
||||
public Response createBand(@Context HttpServletRequest request, MusicBand musicBand) {
|
||||
try {
|
||||
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
|
||||
if (principal == null) {
|
||||
return Response.status(Response.Status.UNAUTHORIZED)
|
||||
.entity(Map.of("error", "Authentication required"))
|
||||
.build();
|
||||
}
|
||||
|
||||
musicBand.setCreatedBy(principal.getUserId());
|
||||
MusicBand createdBand = musicBandService.create(musicBand);
|
||||
return Response.status(Response.Status.CREATED).entity(createdBand).build();
|
||||
} catch (MusicBandService.ValidationException e) {
|
||||
} catch (MusicBandService.ValidationException | MusicBandService.BusinessConstraintException e) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
|
|
@ -87,15 +98,37 @@ public class MusicBandResource {
|
|||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public Response updateBand(@PathParam("id") Long id, MusicBand musicBand) {
|
||||
public Response updateBand(@Context HttpServletRequest request,
|
||||
@PathParam("id") Long id,
|
||||
MusicBand musicBand) {
|
||||
try {
|
||||
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
|
||||
if (principal == null) {
|
||||
return Response.status(Response.Status.UNAUTHORIZED)
|
||||
.entity(Map.of("error", "Authentication required"))
|
||||
.build();
|
||||
}
|
||||
|
||||
Optional<MusicBand> existingBand = musicBandService.findById(id);
|
||||
if (existingBand.isEmpty()) {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("error", "Music band not found"))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (!principal.isAdmin() && !principal.getUserId().equals(existingBand.get().getCreatedBy())) {
|
||||
return Response.status(Response.Status.FORBIDDEN)
|
||||
.entity(Map.of("error", "You can only edit your own bands"))
|
||||
.build();
|
||||
}
|
||||
|
||||
MusicBand updatedBand = musicBandService.update(id, musicBand);
|
||||
return Response.ok(updatedBand).build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
} catch (MusicBandService.ValidationException e) {
|
||||
} catch (MusicBandService.ValidationException | MusicBandService.BusinessConstraintException e) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
|
|
@ -108,8 +141,28 @@ public class MusicBandResource {
|
|||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response deleteBand(@PathParam("id") Long id) {
|
||||
public Response deleteBand(@Context HttpServletRequest request, @PathParam("id") Long id) {
|
||||
try {
|
||||
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
|
||||
if (principal == null) {
|
||||
return Response.status(Response.Status.UNAUTHORIZED)
|
||||
.entity(Map.of("error", "Authentication required"))
|
||||
.build();
|
||||
}
|
||||
|
||||
Optional<MusicBand> existingBand = musicBandService.findById(id);
|
||||
if (existingBand.isEmpty()) {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("error", "Music band not found"))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (!principal.isAdmin() && !principal.getUserId().equals(existingBand.get().getCreatedBy())) {
|
||||
return Response.status(Response.Status.FORBIDDEN)
|
||||
.entity(Map.of("error", "You can only delete your own bands"))
|
||||
.build();
|
||||
}
|
||||
|
||||
musicBandService.delete(id);
|
||||
return Response.noContent().build();
|
||||
} catch (Exception e) {
|
||||
|
|
@ -166,8 +219,28 @@ public class MusicBandResource {
|
|||
|
||||
@POST
|
||||
@Path("/{id}/add-single")
|
||||
public Response addSingle(@PathParam("id") Long id) {
|
||||
public Response addSingle(@Context HttpServletRequest request, @PathParam("id") Long id) {
|
||||
try {
|
||||
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
|
||||
if (principal == null) {
|
||||
return Response.status(Response.Status.UNAUTHORIZED)
|
||||
.entity(Map.of("error", "Authentication required"))
|
||||
.build();
|
||||
}
|
||||
|
||||
Optional<MusicBand> existingBand = musicBandService.findById(id);
|
||||
if (existingBand.isEmpty()) {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("error", "Music band not found"))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (!principal.isAdmin() && !principal.getUserId().equals(existingBand.get().getCreatedBy())) {
|
||||
return Response.status(Response.Status.FORBIDDEN)
|
||||
.entity(Map.of("error", "You can only modify your own bands"))
|
||||
.build();
|
||||
}
|
||||
|
||||
musicBandService.addSingle(id);
|
||||
return Response.ok(Map.of("message", "Single added successfully", "bandId", id)).build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
|
|
@ -183,8 +256,28 @@ public class MusicBandResource {
|
|||
|
||||
@POST
|
||||
@Path("/{id}/remove-participant")
|
||||
public Response removeParticipant(@PathParam("id") Long id) {
|
||||
public Response removeParticipant(@Context HttpServletRequest request, @PathParam("id") Long id) {
|
||||
try {
|
||||
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
|
||||
if (principal == null) {
|
||||
return Response.status(Response.Status.UNAUTHORIZED)
|
||||
.entity(Map.of("error", "Authentication required"))
|
||||
.build();
|
||||
}
|
||||
|
||||
Optional<MusicBand> existingBand = musicBandService.findById(id);
|
||||
if (existingBand.isEmpty()) {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("error", "Music band not found"))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (!principal.isAdmin() && !principal.getUserId().equals(existingBand.get().getCreatedBy())) {
|
||||
return Response.status(Response.Status.FORBIDDEN)
|
||||
.entity(Map.of("error", "You can only modify your own bands"))
|
||||
.build();
|
||||
}
|
||||
|
||||
musicBandService.removeParticipant(id);
|
||||
return Response.ok(Map.of("message", "Participant removed successfully", "bandId", id)).build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
package ru.akarpov.is1.security;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.servlet.*;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import ru.akarpov.is1.entity.Role;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class AuthenticationFilter implements Filter {
|
||||
|
||||
@Inject
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
|
||||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||||
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
||||
|
||||
String path = httpRequest.getRequestURI();
|
||||
|
||||
if (path.startsWith("/is1/api/auth/") ||
|
||||
path.startsWith("/is1/api/test-password/") ||
|
||||
path.equals("/is1/") ||
|
||||
path.startsWith("/is1/index.html") ||
|
||||
path.startsWith("/is1/css/") ||
|
||||
path.startsWith("/is1/js/") ||
|
||||
path.startsWith("/is1/websocket/") ||
|
||||
path.equals("/is1/api/test-db/connection")) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("OPTIONS".equalsIgnoreCase(httpRequest.getMethod())) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String authHeader = httpRequest.getHeader("Authorization");
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
httpResponse.getWriter().write("{\"error\":\"Missing or invalid Authorization header\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
String token = authHeader.substring(7);
|
||||
|
||||
if (!jwtUtil.validateToken(token)) {
|
||||
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
httpResponse.getWriter().write("{\"error\":\"Invalid or expired token\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Long userId = jwtUtil.getUserId(token);
|
||||
String username = jwtUtil.getUsername(token);
|
||||
Role role = jwtUtil.getRole(token);
|
||||
|
||||
UserPrincipal principal = new UserPrincipal(userId, username, role);
|
||||
httpRequest.setAttribute("userPrincipal", principal);
|
||||
|
||||
chain.doFilter(request, response);
|
||||
} catch (Exception e) {
|
||||
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
httpResponse.getWriter().write("{\"error\":\"Authentication failed\"}");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(FilterConfig filterConfig) throws ServletException {}
|
||||
|
||||
@Override
|
||||
public void destroy() {}
|
||||
}
|
||||
61
src/main/java/ru/akarpov/is1/security/JwtUtil.java
Normal file
61
src/main/java/ru/akarpov/is1/security/JwtUtil.java
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package ru.akarpov.is1.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.SignatureAlgorithm;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import ru.akarpov.is1.entity.Role;
|
||||
|
||||
import java.security.Key;
|
||||
import java.util.Date;
|
||||
|
||||
@ApplicationScoped
|
||||
public class JwtUtil {
|
||||
private static final String SECRET_KEY = "your-very-secure-secret-key-that-is-at-least-256-bits-long-for-hs256";
|
||||
private static final long EXPIRATION_TIME = 86400000;
|
||||
private final Key key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
|
||||
|
||||
public String generateToken(Long userId, String username, Role role) {
|
||||
return Jwts.builder()
|
||||
.setSubject(username)
|
||||
.claim("userId", userId)
|
||||
.claim("role", role.name())
|
||||
.setIssuedAt(new Date())
|
||||
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
|
||||
.signWith(key, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parseToken(String token) {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(key)
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
}
|
||||
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
Claims claims = parseToken(token);
|
||||
return claims.getExpiration().after(new Date());
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Long getUserId(String token) {
|
||||
Claims claims = parseToken(token);
|
||||
return claims.get("userId", Long.class);
|
||||
}
|
||||
|
||||
public String getUsername(String token) {
|
||||
return parseToken(token).getSubject();
|
||||
}
|
||||
|
||||
public Role getRole(String token) {
|
||||
Claims claims = parseToken(token);
|
||||
String roleName = claims.get("role", String.class);
|
||||
return Role.valueOf(roleName);
|
||||
}
|
||||
}
|
||||
16
src/main/java/ru/akarpov/is1/security/PasswordUtil.java
Normal file
16
src/main/java/ru/akarpov/is1/security/PasswordUtil.java
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package ru.akarpov.is1.security;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.mindrot.jbcrypt.BCrypt;
|
||||
|
||||
@ApplicationScoped
|
||||
public class PasswordUtil {
|
||||
|
||||
public String hashPassword(String plainPassword) {
|
||||
return BCrypt.hashpw(plainPassword, BCrypt.gensalt(10));
|
||||
}
|
||||
|
||||
public boolean checkPassword(String plainPassword, String hashedPassword) {
|
||||
return BCrypt.checkpw(plainPassword, hashedPassword);
|
||||
}
|
||||
}
|
||||
31
src/main/java/ru/akarpov/is1/security/UserPrincipal.java
Normal file
31
src/main/java/ru/akarpov/is1/security/UserPrincipal.java
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package ru.akarpov.is1.security;
|
||||
|
||||
import ru.akarpov.is1.entity.Role;
|
||||
|
||||
public class UserPrincipal {
|
||||
private final Long userId;
|
||||
private final String username;
|
||||
private final Role role;
|
||||
|
||||
public UserPrincipal(Long userId, String username, Role role) {
|
||||
this.userId = userId;
|
||||
this.username = username;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public Role getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public boolean isAdmin() {
|
||||
return role == Role.ADMIN;
|
||||
}
|
||||
}
|
||||
63
src/main/java/ru/akarpov/is1/service/AuthService.java
Normal file
63
src/main/java/ru/akarpov/is1/service/AuthService.java
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package ru.akarpov.is1.service;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import ru.akarpov.is1.dto.AuthResponse;
|
||||
import ru.akarpov.is1.dto.LoginRequest;
|
||||
import ru.akarpov.is1.dto.RegisterRequest;
|
||||
import ru.akarpov.is1.entity.Role;
|
||||
import ru.akarpov.is1.entity.User;
|
||||
import ru.akarpov.is1.repository.UserRepository;
|
||||
import ru.akarpov.is1.security.JwtUtil;
|
||||
import ru.akarpov.is1.security.PasswordUtil;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@ApplicationScoped
|
||||
public class AuthService {
|
||||
|
||||
@Inject
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Inject
|
||||
private PasswordUtil passwordUtil;
|
||||
|
||||
@Inject
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@Transactional
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
if (userRepository.existsByUsername(request.getUsername())) {
|
||||
throw new IllegalArgumentException("Username already exists");
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(request.getUsername());
|
||||
user.setPasswordHash(passwordUtil.hashPassword(request.getPassword()));
|
||||
user.setRole(Role.USER);
|
||||
|
||||
user = userRepository.save(user);
|
||||
|
||||
String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole());
|
||||
return new AuthResponse(token, user.getId(), user.getUsername(), user.getRole().name());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
|
||||
|
||||
if (userOpt.isEmpty()) {
|
||||
throw new IllegalArgumentException("Invalid username or password");
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
|
||||
if (!passwordUtil.checkPassword(request.getPassword(), user.getPasswordHash())) {
|
||||
throw new IllegalArgumentException("Invalid username or password");
|
||||
}
|
||||
|
||||
String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole());
|
||||
return new AuthResponse(token, user.getId(), user.getUsername(), user.getRole().name());
|
||||
}
|
||||
}
|
||||
127
src/main/java/ru/akarpov/is1/service/ImportService.java
Normal file
127
src/main/java/ru/akarpov/is1/service/ImportService.java
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package ru.akarpov.is1.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import ru.akarpov.is1.dto.ImportHistoryResponse;
|
||||
import ru.akarpov.is1.entity.*;
|
||||
import ru.akarpov.is1.repository.ImportOperationRepository;
|
||||
import ru.akarpov.is1.repository.UserRepository;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
@ApplicationScoped
|
||||
public class ImportService {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(ImportService.class.getName());
|
||||
|
||||
@Inject
|
||||
private MusicBandService musicBandService;
|
||||
|
||||
@Inject
|
||||
private ImportOperationRepository importOperationRepository;
|
||||
|
||||
@Inject
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Transactional
|
||||
public ImportOperation importBands(String fileContent, String fileFormat, Long userId) {
|
||||
List<MusicBand> bands;
|
||||
ImportOperation operation = new ImportOperation();
|
||||
operation.setUserId(userId);
|
||||
|
||||
try {
|
||||
bands = parseFile(fileContent, fileFormat);
|
||||
|
||||
if (bands.isEmpty()) {
|
||||
throw new IllegalArgumentException("No valid bands found in file");
|
||||
}
|
||||
|
||||
for (MusicBand band : bands) {
|
||||
band.setCreatedBy(userId);
|
||||
musicBandService.create(band);
|
||||
}
|
||||
|
||||
operation.setStatus(ImportStatus.SUCCESS);
|
||||
operation.setObjectsCount(bands.size());
|
||||
|
||||
logger.info("Successfully imported " + bands.size() + " bands for user " + userId);
|
||||
|
||||
} catch (Exception e) {
|
||||
operation.setStatus(ImportStatus.FAILED);
|
||||
operation.setErrorMessage(e.getMessage());
|
||||
logger.log(Level.SEVERE, "Import failed for user " + userId, e);
|
||||
}
|
||||
|
||||
return importOperationRepository.save(operation);
|
||||
}
|
||||
|
||||
private List<MusicBand> parseFile(String content, String format) throws IOException {
|
||||
ObjectMapper mapper;
|
||||
|
||||
switch (format.toLowerCase()) {
|
||||
case "json":
|
||||
mapper = new ObjectMapper();
|
||||
mapper.findAndRegisterModules();
|
||||
break;
|
||||
case "xml":
|
||||
mapper = new XmlMapper();
|
||||
mapper.findAndRegisterModules();
|
||||
break;
|
||||
case "yaml":
|
||||
case "yml":
|
||||
mapper = new YAMLMapper();
|
||||
mapper.findAndRegisterModules();
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unsupported format: " + format);
|
||||
}
|
||||
|
||||
MusicBandsWrapper wrapper = mapper.readValue(content, MusicBandsWrapper.class);
|
||||
return wrapper.getBands() != null ? wrapper.getBands() : new ArrayList<>();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<ImportHistoryResponse> getImportHistory(Long userId, boolean isAdmin) {
|
||||
List<ImportOperation> operations = isAdmin ?
|
||||
importOperationRepository.findAll() :
|
||||
importOperationRepository.findByUser(userId);
|
||||
|
||||
List<ImportHistoryResponse> responses = new ArrayList<>();
|
||||
for (ImportOperation op : operations) {
|
||||
String username = userRepository.findById(op.getUserId())
|
||||
.map(User::getUsername)
|
||||
.orElse("Unknown");
|
||||
|
||||
responses.add(new ImportHistoryResponse(
|
||||
op.getId(),
|
||||
username,
|
||||
op.getStatus().name(),
|
||||
op.getObjectsCount(),
|
||||
op.getErrorMessage(),
|
||||
op.getCreatedAt()
|
||||
));
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
public static class MusicBandsWrapper {
|
||||
private List<MusicBand> bands;
|
||||
|
||||
public List<MusicBand> getBands() {
|
||||
return bands;
|
||||
}
|
||||
|
||||
public void setBands(List<MusicBand> bands) {
|
||||
this.bands = bands;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ public class MusicBandService {
|
|||
|
||||
try {
|
||||
validateMusicBand(musicBand);
|
||||
checkBusinessConstraints(musicBand, 0L);
|
||||
logger.fine("Validation passed for band: " + musicBand.getName());
|
||||
|
||||
MusicBand created = musicBandRepository.save(musicBand);
|
||||
|
|
@ -42,7 +43,7 @@ public class MusicBandService {
|
|||
logger.fine("WebSocket broadcast sent for created band ID: " + created.getId());
|
||||
|
||||
return created;
|
||||
} catch (ValidationException e) {
|
||||
} catch (ValidationException | BusinessConstraintException e) {
|
||||
logger.warning("Validation failed for band: " + e.getMessage());
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
|
|
@ -51,27 +52,32 @@ public class MusicBandService {
|
|||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Optional<MusicBand> findById(Long id) {
|
||||
logger.fine("Finding band by ID: " + id);
|
||||
return musicBandRepository.findById(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<MusicBand> findAll(int page, int size) {
|
||||
logger.fine("Finding all bands - page: " + page + ", size: " + size);
|
||||
return musicBandRepository.findAll(page, size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<MusicBand> findAllSorted(String sortBy, boolean ascending, int page, int size) {
|
||||
logger.fine("Finding all bands sorted by: " + sortBy + " (" + (ascending ? "ASC" : "DESC") + ")");
|
||||
validateSortField(sortBy);
|
||||
return musicBandRepository.findAllSorted(sortBy, ascending, page, size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<MusicBand> findByNameContaining(String name, int page, int size) {
|
||||
logger.fine("Finding bands by name containing: " + name);
|
||||
return musicBandRepository.findByNameContainingSorted(name, "id", true, page, size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<MusicBand> findByNameContainingSorted(String name, String sortBy, boolean ascending, int page, int size) {
|
||||
logger.fine("Finding bands by name containing: " + name + ", sorted by: " + sortBy);
|
||||
validateSortField(sortBy);
|
||||
|
|
@ -91,8 +97,10 @@ public class MusicBandService {
|
|||
|
||||
updatedBand.setId(id);
|
||||
updatedBand.setCreationDate(existingBand.get().getCreationDate());
|
||||
updatedBand.setCreatedBy(existingBand.get().getCreatedBy());
|
||||
|
||||
validateMusicBand(updatedBand);
|
||||
checkBusinessConstraints(updatedBand, id);
|
||||
logger.fine("Validation passed for band update: " + id);
|
||||
|
||||
MusicBand updated = musicBandRepository.save(updatedBand);
|
||||
|
|
@ -102,7 +110,7 @@ public class MusicBandService {
|
|||
logger.fine("WebSocket broadcast sent for updated band ID: " + updated.getId());
|
||||
|
||||
return updated;
|
||||
} catch (ValidationException | IllegalArgumentException e) {
|
||||
} catch (ValidationException | BusinessConstraintException | IllegalArgumentException e) {
|
||||
logger.warning("Update failed for band ID " + id + ": " + e.getMessage());
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
|
|
@ -135,12 +143,14 @@ public class MusicBandService {
|
|||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public long count() {
|
||||
long count = musicBandRepository.count();
|
||||
logger.fine("Total band count: " + count);
|
||||
return count;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Double getAverageAlbumsCount() {
|
||||
logger.fine("Calculating average albums count");
|
||||
Double average = musicBandRepository.getAverageAlbumsCount();
|
||||
|
|
@ -148,6 +158,7 @@ public class MusicBandService {
|
|||
return average != null ? average : 0.0;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MusicBand findWithMaxName() {
|
||||
logger.fine("Finding band with max name");
|
||||
MusicBand band = musicBandRepository.findWithMaxName();
|
||||
|
|
@ -155,6 +166,7 @@ public class MusicBandService {
|
|||
return band;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<Long, Long> groupByNumberOfParticipants() {
|
||||
logger.fine("Grouping bands by number of participants");
|
||||
Map<Long, Long> groups = musicBandRepository.groupByNumberOfParticipants();
|
||||
|
|
@ -225,6 +237,33 @@ public class MusicBandService {
|
|||
}
|
||||
}
|
||||
|
||||
private void checkBusinessConstraints(MusicBand musicBand, Long excludeId) throws BusinessConstraintException {
|
||||
Long nameGenreCount = musicBandRepository.checkNameGenreUnique(
|
||||
musicBand.getName(),
|
||||
musicBand.getGenre(),
|
||||
excludeId
|
||||
);
|
||||
|
||||
if (nameGenreCount > 0) {
|
||||
throw new BusinessConstraintException(
|
||||
"Band with name '" + musicBand.getName() +
|
||||
"' and genre '" + musicBand.getGenre() + "' already exists"
|
||||
);
|
||||
}
|
||||
|
||||
Long establishmentDateCount = musicBandRepository.checkEstablishmentDateUnique(
|
||||
musicBand.getEstablishmentDate(),
|
||||
excludeId
|
||||
);
|
||||
|
||||
if (establishmentDateCount > 0) {
|
||||
throw new BusinessConstraintException(
|
||||
"Band with establishment date '" + musicBand.getEstablishmentDate() +
|
||||
"' already exists"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSortField(String sortBy) {
|
||||
Set<String> allowedFields = Set.of("id", "name", "genre", "numberOfParticipants",
|
||||
"singlesCount", "albumsCount", "establishmentDate", "creationDate");
|
||||
|
|
@ -239,4 +278,10 @@ public class MusicBandService {
|
|||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static class BusinessConstraintException extends RuntimeException {
|
||||
public BusinessConstraintException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
<class>ru.akarpov.is1.entity.Album</class>
|
||||
<class>ru.akarpov.is1.entity.Person</class>
|
||||
<class>ru.akarpov.is1.entity.Location</class>
|
||||
<class>ru.akarpov.is1.entity.User</class>
|
||||
<class>ru.akarpov.is1.entity.ImportOperation</class>
|
||||
|
||||
<properties>
|
||||
<property name="eclipselink.target-server" value="JBoss"/>
|
||||
|
|
@ -24,9 +26,9 @@
|
|||
<property name="eclipselink.logging.parameters" value="true"/>
|
||||
<property name="eclipselink.connection-pool.default.initial" value="1"/>
|
||||
<property name="eclipselink.connection-pool.default.min" value="1"/>
|
||||
<property name="eclipselink.connection-pool.default.max" value="5"/>
|
||||
<property name="eclipselink.jpa.persistence-context.reference-mode" value="FORCE_WEAK"/>
|
||||
<property name="eclipselink.connection-pool.default.max" value="10"/>
|
||||
<property name="eclipselink.cache.shared.default" value="false"/>
|
||||
<property name="eclipselink.flush-clear.cache" value="Drop"/>
|
||||
</properties>
|
||||
</persistence-unit>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,4 +20,14 @@
|
|||
<url-pattern>/*</url-pattern>
|
||||
</filter-mapping>
|
||||
|
||||
<filter>
|
||||
<filter-name>AuthenticationFilter</filter-name>
|
||||
<filter-class>ru.akarpov.is1.security.AuthenticationFilter</filter-class>
|
||||
</filter>
|
||||
|
||||
<filter-mapping>
|
||||
<filter-name>AuthenticationFilter</filter-name>
|
||||
<url-pattern>/api/*</url-pattern>
|
||||
</filter-mapping>
|
||||
|
||||
</web-app>
|
||||
|
|
@ -38,6 +38,95 @@ body {
|
|||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.auth-screen {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
width: min(450px, 100%);
|
||||
padding: 40px 36px;
|
||||
}
|
||||
|
||||
.auth-container .brand.centered {
|
||||
justify-content: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.auth-form h2 {
|
||||
margin: 0 0 32px 0;
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-form .form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.auth-form .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-form .form-group input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: #0e1017;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.auth-form .form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--focus);
|
||||
box-shadow: 0 0 0 3px rgba(45,212,191,0.15);
|
||||
}
|
||||
|
||||
.auth-form button[type="submit"] {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
color: var(--brand-2);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
padding: 14px 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-shell { max-width: 1200px; padding: 24px; margin: 0 auto; }
|
||||
|
||||
.card {
|
||||
|
|
@ -62,7 +151,7 @@ body {
|
|||
.brand-text h1 { font-size: 18px; margin: 0; letter-spacing: 0.3px; }
|
||||
.muted { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.top-nav { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.top-nav { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
.nav-btn {
|
||||
appearance: none;
|
||||
|
|
@ -101,13 +190,20 @@ body {
|
|||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
padding: 12px 14px;
|
||||
background: #0e1017;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field input[type="file"],
|
||||
.form-group input[type="file"] {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.form-group input:focus,
|
||||
|
|
@ -115,7 +211,7 @@ body {
|
|||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--focus);
|
||||
box-shadow: 0 0 0 2px rgba(45,212,191,0.2);
|
||||
box-shadow: 0 0 0 3px rgba(45,212,191,0.15);
|
||||
}
|
||||
|
||||
.field-row { display: flex; gap: 8px; }
|
||||
|
|
@ -203,6 +299,23 @@ body {
|
|||
.pagination { display: flex; align-items: center; justify-content: center; gap: 10px; padding-top: 10px; }
|
||||
.pagination .muted { font-size: 12px; }
|
||||
|
||||
.import-container {
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.import-info {
|
||||
background: var(--panel-soft);
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.import-info p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none; position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
|
||||
|
|
@ -230,12 +343,33 @@ body {
|
|||
.modal-title { margin: 0 0 8px 0; }
|
||||
.close { position: absolute; right: 12px; top: 12px; z-index: 1; }
|
||||
|
||||
.form { margin-top: 10px; }
|
||||
.form-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); }
|
||||
.form-group label { display: block; margin: 6px 0 6px; color: var(--muted); font-size: 12px; }
|
||||
.subhead { margin: 16px 0 6px; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.form { margin-top: 16px; }
|
||||
.form-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); }
|
||||
.form-group { margin-bottom: 4px; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.subhead {
|
||||
margin: 24px 0 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
|
|
@ -443,4 +577,5 @@ body {
|
|||
.section-head { flex-direction: column; align-items: flex-start; }
|
||||
.controls { width: 100%; }
|
||||
.filters { width: 100%; flex-wrap: wrap; }
|
||||
.auth-container { padding: 24px; }
|
||||
}
|
||||
|
|
@ -11,13 +11,67 @@
|
|||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<div id="authScreen" class="auth-screen">
|
||||
<div class="auth-container card">
|
||||
<div class="brand centered">
|
||||
<div class="brand-mark" aria-hidden="true">♬</div>
|
||||
<div class="brand-text">
|
||||
<h1>Music Band IS</h1>
|
||||
<span class="muted">Information System</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loginForm" class="auth-form">
|
||||
<h2>Login</h2>
|
||||
<form id="loginFormElement">
|
||||
<div class="form-group">
|
||||
<label for="loginUsername">Username</label>
|
||||
<input type="text" id="loginUsername" required autocomplete="username" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="loginPassword">Password</label>
|
||||
<input type="password" id="loginPassword" required autocomplete="current-password" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary full-width">Login</button>
|
||||
</form>
|
||||
<p class="auth-switch">
|
||||
Don't have an account?
|
||||
<button type="button" class="link-btn" id="showRegisterBtn">Register</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="registerForm" class="auth-form" style="display: none;">
|
||||
<h2>Register</h2>
|
||||
<form id="registerFormElement">
|
||||
<div class="form-group">
|
||||
<label for="registerUsername">Username</label>
|
||||
<input type="text" id="registerUsername" required minlength="3" maxlength="50" autocomplete="username" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="registerPassword">Password</label>
|
||||
<input type="password" id="registerPassword" required minlength="6" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="registerPasswordConfirm">Confirm Password</label>
|
||||
<input type="password" id="registerPasswordConfirm" required minlength="6" autocomplete="new-password" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary full-width">Register</button>
|
||||
</form>
|
||||
<p class="auth-switch">
|
||||
Already have an account?
|
||||
<button type="button" class="link-btn" id="showLoginBtn">Login</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="appScreen" class="app-shell" style="display: none;">
|
||||
<header class="site-header card">
|
||||
<div class="brand">
|
||||
<div class="brand-mark" aria-hidden="true">♬</div>
|
||||
<div class="brand-text">
|
||||
<h1>Music Band IS</h1>
|
||||
<span class="muted">admin panel</span>
|
||||
<span class="muted" id="userInfo">admin panel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -25,11 +79,13 @@
|
|||
<button id="showBandsBtn" class="nav-btn active" aria-current="page">All Bands</button>
|
||||
<button id="showStatisticsBtn" class="nav-btn">Statistics</button>
|
||||
<button id="showSpecialOpsBtn" class="nav-btn">Special Ops</button>
|
||||
<button id="showImportBtn" class="nav-btn">Import</button>
|
||||
<button id="showHistoryBtn" class="nav-btn">History</button>
|
||||
<button id="logoutBtn" class="btn btn-ghost">Logout</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="site-main">
|
||||
<!-- Bands -->
|
||||
<section id="bandsSection" class="section card active" aria-labelledby="bandsTitle">
|
||||
<div class="section-head">
|
||||
<h2 id="bandsTitle">Bands</h2>
|
||||
|
|
@ -84,7 +140,6 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Statistics -->
|
||||
<section id="statisticsSection" class="section card" aria-labelledby="statsTitle">
|
||||
<div class="section-head">
|
||||
<h2 id="statsTitle">Statistics</h2>
|
||||
|
|
@ -111,7 +166,6 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Special Ops -->
|
||||
<section id="specialOpsSection" class="section card" aria-labelledby="opsTitle">
|
||||
<div class="section-head">
|
||||
<h2 id="opsTitle">Special Operations</h2>
|
||||
|
|
@ -135,10 +189,62 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="importSection" class="section card" aria-labelledby="importTitle">
|
||||
<div class="section-head">
|
||||
<h2 id="importTitle">Import Bands</h2>
|
||||
</div>
|
||||
|
||||
<div class="import-container">
|
||||
<div class="import-info">
|
||||
<p>Upload a file containing band data in JSON, XML, or YAML format.</p>
|
||||
<p class="muted">The format will be automatically detected based on file extension.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="importFile">Select File</label>
|
||||
<input type="file" id="importFile" accept=".json,.xml,.yaml,.yml" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="importFormat">Format</label>
|
||||
<select id="importFormat">
|
||||
<option value="">Auto-detect</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="xml">XML</option>
|
||||
<option value="yaml">YAML</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button id="importBtn" class="btn btn-primary">Import</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="historySection" class="section card" aria-labelledby="historyTitle">
|
||||
<div class="section-head">
|
||||
<h2 id="historyTitle">Import History</h2>
|
||||
<button id="refreshHistoryBtn" class="btn btn-ghost">Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table id="historyTable" class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
<th>Status</th>
|
||||
<th>Objects</th>
|
||||
<th>Date</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="historyTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div id="bandModal" class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<div class="modal-content card">
|
||||
<button class="close icon-btn" aria-label="Close">×</button>
|
||||
|
|
@ -270,4 +376,4 @@
|
|||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
@ -9,23 +9,180 @@ class MusicBandApp {
|
|||
this.editingBandId = null;
|
||||
this.websocket = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.token = localStorage.getItem('token');
|
||||
this.user = JSON.parse(localStorage.getItem('user') || 'null');
|
||||
|
||||
this.toastEl = document.getElementById('toast');
|
||||
|
||||
this.initializeApp();
|
||||
}
|
||||
|
||||
initializeApp() {
|
||||
if (this.token && this.user) {
|
||||
this.showAppScreen();
|
||||
} else {
|
||||
this.showAuthScreen();
|
||||
}
|
||||
}
|
||||
|
||||
showAuthScreen() {
|
||||
document.getElementById('authScreen').style.display = 'grid';
|
||||
document.getElementById('appScreen').style.display = 'none';
|
||||
this.initializeAuthListeners();
|
||||
}
|
||||
|
||||
showAppScreen() {
|
||||
document.getElementById('authScreen').style.display = 'none';
|
||||
document.getElementById('appScreen').style.display = 'block';
|
||||
|
||||
document.getElementById('userInfo').textContent =
|
||||
`${this.user.username} (${this.user.role})`;
|
||||
|
||||
this.initializeEventListeners();
|
||||
this.loadBands();
|
||||
|
||||
setTimeout(() => {
|
||||
this.initializeWebSocket();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
console.log('[App] MusicBandApp initialized');
|
||||
initializeAuthListeners() {
|
||||
document.getElementById('showRegisterBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
document.getElementById('registerForm').style.display = 'block';
|
||||
});
|
||||
|
||||
document.getElementById('showLoginBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('registerForm').style.display = 'none';
|
||||
document.getElementById('loginForm').style.display = 'block';
|
||||
});
|
||||
|
||||
document.getElementById('loginFormElement')?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.login();
|
||||
});
|
||||
|
||||
document.getElementById('registerFormElement')?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.register();
|
||||
});
|
||||
}
|
||||
|
||||
async login() {
|
||||
const username = document.getElementById('loginUsername').value.trim();
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
|
||||
if (!username || !password) {
|
||||
this.showError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Login failed');
|
||||
}
|
||||
|
||||
this.token = data.token;
|
||||
this.user = {
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
role: data.role
|
||||
};
|
||||
|
||||
localStorage.setItem('token', this.token);
|
||||
localStorage.setItem('user', JSON.stringify(this.user));
|
||||
|
||||
this.showSuccess('Login successful!');
|
||||
this.showAppScreen();
|
||||
} catch (error) {
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async register() {
|
||||
const username = document.getElementById('registerUsername').value.trim();
|
||||
const password = document.getElementById('registerPassword').value;
|
||||
const passwordConfirm = document.getElementById('registerPasswordConfirm').value;
|
||||
|
||||
if (!username || !password || !passwordConfirm) {
|
||||
this.showError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
this.showError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
this.showError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Registration failed');
|
||||
}
|
||||
|
||||
this.token = data.token;
|
||||
this.user = {
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
role: data.role
|
||||
};
|
||||
|
||||
localStorage.setItem('token', this.token);
|
||||
localStorage.setItem('user', JSON.stringify(this.user));
|
||||
|
||||
this.showSuccess('Registration successful!');
|
||||
this.showAppScreen();
|
||||
} catch (error) {
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.token = null;
|
||||
this.user = null;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
|
||||
if (this.websocket) {
|
||||
this.websocket.close();
|
||||
this.websocket = null;
|
||||
}
|
||||
|
||||
this.showAuthScreen();
|
||||
this.showInfo('Logged out successfully');
|
||||
}
|
||||
|
||||
initializeEventListeners() {
|
||||
document.getElementById('logoutBtn').addEventListener('click', () => this.logout());
|
||||
|
||||
document.getElementById('showBandsBtn').addEventListener('click', (e) => this.showSection('bandsSection', e.currentTarget));
|
||||
document.getElementById('showStatisticsBtn').addEventListener('click', (e) => this.showSection('statisticsSection', e.currentTarget));
|
||||
document.getElementById('showSpecialOpsBtn').addEventListener('click', (e) => this.showSection('specialOpsSection', e.currentTarget));
|
||||
document.getElementById('showImportBtn').addEventListener('click', (e) => this.showSection('importSection', e.currentTarget));
|
||||
document.getElementById('showHistoryBtn').addEventListener('click', (e) => {
|
||||
this.showSection('historySection', e.currentTarget);
|
||||
this.loadImportHistory();
|
||||
});
|
||||
|
||||
document.getElementById('addBandBtn').addEventListener('click', () => this.showAddBandModal());
|
||||
document.getElementById('nameFilter').addEventListener('input', (e) => this.filterBands(e.target.value));
|
||||
|
|
@ -43,6 +200,9 @@ class MusicBandApp {
|
|||
document.getElementById('addSingleBtn').addEventListener('click', () => this.addSingle());
|
||||
document.getElementById('removeParticipantBtn').addEventListener('click', () => this.removeParticipant());
|
||||
|
||||
document.getElementById('importBtn').addEventListener('click', () => this.importFile());
|
||||
document.getElementById('refreshHistoryBtn').addEventListener('click', () => this.loadImportHistory());
|
||||
|
||||
document.querySelector('.close').addEventListener('click', () => this.closeModal());
|
||||
document.getElementById('cancelBtn').addEventListener('click', () => this.closeModal());
|
||||
document.getElementById('bandForm').addEventListener('submit', (e) => this.saveBand(e));
|
||||
|
|
@ -51,19 +211,21 @@ class MusicBandApp {
|
|||
if (e.target === document.getElementById('bandModal')) this.closeModal();
|
||||
if (e.target === document.getElementById('viewModal')) this.closeViewModal();
|
||||
});
|
||||
|
||||
console.log('[App] Event listeners initialized');
|
||||
}
|
||||
|
||||
async makeRequest(url, options = {}) {
|
||||
console.log(`[API] ${options.method || 'GET'} ${url}`);
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
if (this.token && !url.includes('/auth/')) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
headers,
|
||||
...options
|
||||
});
|
||||
|
||||
|
|
@ -73,9 +235,7 @@ class MusicBandApp {
|
|||
if (contentType && contentType.includes('application/json')) {
|
||||
try {
|
||||
data = await response.json();
|
||||
console.log(`[API] Response:`, data);
|
||||
} catch (e) {
|
||||
console.warn('[API] Failed to parse JSON response');
|
||||
data = {};
|
||||
}
|
||||
} else {
|
||||
|
|
@ -83,15 +243,18 @@ class MusicBandApp {
|
|||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
this.showError('Session expired. Please login again.');
|
||||
this.logout();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
const errorMessage = this.extractErrorMessage(data, response.status);
|
||||
console.error(`[API] Error ${response.status}: ${errorMessage}`);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||
console.error('[API] Network error - server unreachable');
|
||||
throw new Error('Failed to connect to server. Please check your connection.');
|
||||
}
|
||||
throw error;
|
||||
|
|
@ -99,33 +262,19 @@ class MusicBandApp {
|
|||
}
|
||||
|
||||
extractErrorMessage(data, status) {
|
||||
if (data.error) {
|
||||
return data.error;
|
||||
}
|
||||
|
||||
if (data.message) {
|
||||
return data.message;
|
||||
}
|
||||
if (data.error) return data.error;
|
||||
if (data.message) return data.message;
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
return 'Invalid request data. Please check your input.';
|
||||
case 401:
|
||||
return 'Authentication required.';
|
||||
case 403:
|
||||
return 'Access denied.';
|
||||
case 404:
|
||||
return 'Resource not found.';
|
||||
case 409:
|
||||
return 'Conflict with existing data.';
|
||||
case 422:
|
||||
return 'Validation failed. Please check your input.';
|
||||
case 500:
|
||||
return 'Internal server error. Please try again later.';
|
||||
case 503:
|
||||
return 'Service temporarily unavailable.';
|
||||
default:
|
||||
return `Request failed with status ${status}`;
|
||||
case 400: return 'Invalid request data. Please check your input.';
|
||||
case 401: return 'Authentication required.';
|
||||
case 403: return 'Access denied.';
|
||||
case 404: return 'Resource not found.';
|
||||
case 409: return 'Conflict with existing data.';
|
||||
case 422: return 'Validation failed. Please check your input.';
|
||||
case 500: return 'Internal server error. Please try again later.';
|
||||
case 503: return 'Service temporarily unavailable.';
|
||||
default: return `Request failed with status ${status}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,12 +283,9 @@ class MusicBandApp {
|
|||
document.querySelectorAll('.nav-btn').forEach((b) => b.classList.remove('active'));
|
||||
document.getElementById(sectionId).classList.add('active');
|
||||
btn.classList.add('active');
|
||||
console.log(`[App] Showing section: ${sectionId}`);
|
||||
}
|
||||
|
||||
async loadBands() {
|
||||
console.log(`[App] Loading bands - page: ${this.currentPage}, filter: '${this.currentFilter}'`);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.currentPage,
|
||||
|
|
@ -157,8 +303,6 @@ class MusicBandApp {
|
|||
const bands = data.bands || [];
|
||||
const totalCount = data.totalCount || 0;
|
||||
|
||||
console.log(`[App] Loaded ${bands.length} bands (total: ${totalCount})`);
|
||||
|
||||
this.renderBandsTable(bands);
|
||||
this.updatePagination(totalCount);
|
||||
|
||||
|
|
@ -166,7 +310,6 @@ class MusicBandApp {
|
|||
this.showInfo('No bands found matching your filter');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[App] Error loading bands:', error);
|
||||
this.showError(error.message);
|
||||
this.renderBandsTable([]);
|
||||
this.updatePagination(0);
|
||||
|
|
@ -186,6 +329,8 @@ class MusicBandApp {
|
|||
|
||||
bands.forEach((band) => {
|
||||
const row = document.createElement('tr');
|
||||
const canEdit = this.user.role === 'ADMIN' || this.user.userId === band.createdBy;
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${band.id}</td>
|
||||
<td>${this.escapeHtml(band.name || '')}</td>
|
||||
|
|
@ -197,14 +342,17 @@ class MusicBandApp {
|
|||
<td>
|
||||
<div class="field-row">
|
||||
<button class="btn btn-primary btn-small" data-action="view">View</button>
|
||||
<button class="btn btn-ghost btn-small" data-action="edit">Edit</button>
|
||||
<button class="btn btn-ghost btn-small" data-action="del">Delete</button>
|
||||
${canEdit ? '<button class="btn btn-ghost btn-small" data-action="edit">Edit</button>' : ''}
|
||||
${canEdit ? '<button class="btn btn-ghost btn-small" data-action="del">Delete</button>' : ''}
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
|
||||
row.querySelector('[data-action="view"]').addEventListener('click', () => this.viewBand(band.id));
|
||||
row.querySelector('[data-action="edit"]').addEventListener('click', () => this.editBand(band.id));
|
||||
row.querySelector('[data-action="del"]').addEventListener('click', () => this.deleteBand(band.id));
|
||||
if (canEdit) {
|
||||
row.querySelector('[data-action="edit"]').addEventListener('click', () => this.editBand(band.id));
|
||||
row.querySelector('[data-action="del"]').addEventListener('click', () => this.deleteBand(band.id));
|
||||
}
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
|
@ -226,7 +374,6 @@ class MusicBandApp {
|
|||
filterBands(filter) {
|
||||
this.currentFilter = filter.trim();
|
||||
this.currentPage = 0;
|
||||
console.log(`[App] Filtering bands: '${this.currentFilter}'`);
|
||||
this.loadBands();
|
||||
}
|
||||
|
||||
|
|
@ -234,7 +381,6 @@ class MusicBandApp {
|
|||
this.currentSort = sortBy;
|
||||
this.currentAscending = ascending;
|
||||
this.currentPage = 0;
|
||||
console.log(`[App] Sorting by: ${sortBy} (${ascending ? 'ASC' : 'DESC'})`);
|
||||
document.getElementById('sortAscBtn').classList.toggle('active', ascending);
|
||||
document.getElementById('sortDescBtn').classList.toggle('active', !ascending);
|
||||
this.loadBands();
|
||||
|
|
@ -253,7 +399,6 @@ class MusicBandApp {
|
|||
}
|
||||
|
||||
showAddBandModal() {
|
||||
console.log('[App] Opening add band modal');
|
||||
this.editingBandId = null;
|
||||
document.getElementById('modalTitle').textContent = 'Add New Band';
|
||||
document.getElementById('bandForm').reset();
|
||||
|
|
@ -272,8 +417,6 @@ class MusicBandApp {
|
|||
}
|
||||
|
||||
async editBand(id) {
|
||||
console.log(`[App] Opening edit modal for band ID: ${id}`);
|
||||
|
||||
try {
|
||||
const band = await this.makeRequest(`${this.API_BASE}/music-bands/${id}`);
|
||||
this.editingBandId = id;
|
||||
|
|
@ -287,8 +430,6 @@ class MusicBandApp {
|
|||
}
|
||||
|
||||
async viewBand(id) {
|
||||
console.log(`[App] Viewing band ID: ${id}`);
|
||||
|
||||
try {
|
||||
const band = await this.makeRequest(`${this.API_BASE}/music-bands/${id}`);
|
||||
this.showViewModal(band);
|
||||
|
|
@ -458,15 +599,11 @@ class MusicBandApp {
|
|||
}
|
||||
|
||||
convertFromZonedDateTime(zonedDateTime) {
|
||||
if (!zonedDateTime) {
|
||||
return '';
|
||||
}
|
||||
if (!zonedDateTime) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(zonedDateTime);
|
||||
if (isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
|
|
@ -476,21 +613,16 @@ class MusicBandApp {
|
|||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
} catch (e) {
|
||||
console.error('[App] Error converting date:', e);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
convertToZonedDateTime(datetimeLocalValue) {
|
||||
if (!datetimeLocalValue) {
|
||||
return null;
|
||||
}
|
||||
if (!datetimeLocalValue) return null;
|
||||
|
||||
try {
|
||||
const date = new Date(datetimeLocalValue);
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new Error('Invalid date format');
|
||||
}
|
||||
if (isNaN(date.getTime())) throw new Error('Invalid date format');
|
||||
|
||||
const offsetMinutes = date.getTimezoneOffset();
|
||||
const offsetHours = Math.abs(Math.floor(offsetMinutes / 60));
|
||||
|
|
@ -507,57 +639,50 @@ class MusicBandApp {
|
|||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offset}`;
|
||||
} catch (e) {
|
||||
console.error('[App] Error converting date:', e);
|
||||
throw new Error('Invalid date format');
|
||||
}
|
||||
}
|
||||
|
||||
async saveBand(event) {
|
||||
event.preventDefault();
|
||||
console.log('[App] Saving band...');
|
||||
async saveBand(event) {
|
||||
event.preventDefault();
|
||||
|
||||
this.clearFormErrors();
|
||||
this.clearFormErrors();
|
||||
|
||||
const bandData = this.collectFormData();
|
||||
console.log('[App] Collected form data:', bandData);
|
||||
const bandData = this.collectFormData();
|
||||
|
||||
if (!this.validateFormData(bandData)) {
|
||||
return;
|
||||
if (!this.validateFormData(bandData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const submitButton = event.target.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
submitButton.textContent = 'Saving...';
|
||||
submitButton.disabled = true;
|
||||
|
||||
try {
|
||||
const url = this.editingBandId
|
||||
? `${this.API_BASE}/music-bands/${this.editingBandId}`
|
||||
: `${this.API_BASE}/music-bands`;
|
||||
const method = this.editingBandId ? 'PUT' : 'POST';
|
||||
|
||||
const savedBand = await this.makeRequest(url, {
|
||||
method,
|
||||
body: JSON.stringify(bandData)
|
||||
});
|
||||
|
||||
this.closeModal();
|
||||
await this.loadBands();
|
||||
|
||||
this.showSuccess(this.editingBandId ? 'Band updated successfully!' : 'Band created successfully!');
|
||||
} catch (error) {
|
||||
this.showError(error.message);
|
||||
this.highlightErrorFields(error.message);
|
||||
} finally {
|
||||
submitButton.textContent = originalText;
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const submitButton = event.target.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
submitButton.textContent = 'Saving...';
|
||||
submitButton.disabled = true;
|
||||
|
||||
try {
|
||||
const url = this.editingBandId
|
||||
? `${this.API_BASE}/music-bands/${this.editingBandId}`
|
||||
: `${this.API_BASE}/music-bands`;
|
||||
const method = this.editingBandId ? 'PUT' : 'POST';
|
||||
|
||||
const savedBand = await this.makeRequest(url, {
|
||||
method,
|
||||
body: JSON.stringify(bandData)
|
||||
});
|
||||
|
||||
console.log('[App] Band saved successfully:', savedBand);
|
||||
|
||||
this.closeModal();
|
||||
await this.loadBands();
|
||||
|
||||
this.showSuccess(this.editingBandId ? 'Band updated successfully!' : 'Band created successfully!');
|
||||
} catch (error) {
|
||||
console.error('[App] Error saving band:', error);
|
||||
this.showError(error.message);
|
||||
this.highlightErrorFields(error.message);
|
||||
} finally {
|
||||
submitButton.textContent = originalText;
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
validateFormData(data) {
|
||||
const errors = [];
|
||||
|
||||
|
|
@ -647,7 +772,6 @@ async saveBand(event) {
|
|||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn('[App] Validation failed:', errors);
|
||||
this.showError('Validation failed:\n• ' + errors.join('\n• '));
|
||||
return false;
|
||||
}
|
||||
|
|
@ -770,39 +894,30 @@ async saveBand(event) {
|
|||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
async deleteBand(id) {
|
||||
const confirmed = await this.showConfirm(
|
||||
'Delete Band',
|
||||
'Are you sure you want to delete this band? This action cannot be undone and will also delete all related data.'
|
||||
);
|
||||
|
||||
async deleteBand(id) {
|
||||
const confirmed = await this.showConfirm(
|
||||
'Delete Band',
|
||||
'Are you sure you want to delete this band? This action cannot be undone and will also delete all related data.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.makeRequest(`${this.API_BASE}/music-bands/${id}`, { method: 'DELETE' });
|
||||
|
||||
console.log(`[App] Deleting band ID: ${id}`);
|
||||
|
||||
try {
|
||||
await this.makeRequest(`${this.API_BASE}/music-bands/${id}`, { method: 'DELETE' });
|
||||
|
||||
console.log(`[App] Band ${id} deleted successfully`);
|
||||
|
||||
if (this.currentPage > 0) {
|
||||
const bands = document.querySelectorAll('#bandsTableBody tr');
|
||||
if (bands.length <= 1) {
|
||||
this.currentPage = Math.max(0, this.currentPage - 1);
|
||||
if (this.currentPage > 0) {
|
||||
const bands = document.querySelectorAll('#bandsTableBody tr');
|
||||
if (bands.length <= 1) {
|
||||
this.currentPage = Math.max(0, this.currentPage - 1);
|
||||
}
|
||||
}
|
||||
|
||||
await this.loadBands();
|
||||
this.showSuccess('Band deleted successfully!');
|
||||
} catch (error) {
|
||||
this.showError(error.message);
|
||||
}
|
||||
|
||||
await this.loadBands();
|
||||
|
||||
this.showSuccess('Band deleted successfully!');
|
||||
} catch (error) {
|
||||
console.error('[App] Error deleting band:', error);
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
document.getElementById('bandModal').style.display = 'none';
|
||||
|
|
@ -810,36 +925,28 @@ async deleteBand(id) {
|
|||
}
|
||||
|
||||
async calculateAverageAlbums() {
|
||||
console.log('[App] Calculating average albums count');
|
||||
|
||||
try {
|
||||
const data = await this.makeRequest(`${this.API_BASE}/music-bands/statistics/average-albums`);
|
||||
const avg = (data.averageAlbumsCount ?? 0).toFixed(2);
|
||||
document.getElementById('avgAlbumsCount').textContent = avg;
|
||||
this.showSuccess(`Average albums count: ${avg}`);
|
||||
} catch (error) {
|
||||
console.error('[App] Error calculating average:', error);
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async findBandWithMaxName() {
|
||||
console.log('[App] Finding band with max name');
|
||||
|
||||
try {
|
||||
const data = await this.makeRequest(`${this.API_BASE}/music-bands/statistics/max-name`);
|
||||
const name = data.name ?? 'No bands found';
|
||||
document.getElementById('maxNameBand').textContent = name;
|
||||
this.showSuccess(`Band with max name: ${name}`);
|
||||
} catch (error) {
|
||||
console.error('[App] Error finding max name:', error);
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async groupByParticipants() {
|
||||
console.log('[App] Grouping bands by participants');
|
||||
|
||||
try {
|
||||
const data = await this.makeRequest(`${this.API_BASE}/music-bands/statistics/group-by-participants`);
|
||||
const lines = Object.entries(data).map(([participants, count]) =>
|
||||
|
|
@ -849,55 +956,139 @@ async deleteBand(id) {
|
|||
lines.join('<br>') : 'No data available';
|
||||
this.showSuccess('Bands grouped by participants');
|
||||
} catch (error) {
|
||||
console.error('[App] Error grouping bands:', error);
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async addSingle() {
|
||||
const bandId = this.parseInteger(document.getElementById('addSingleBandId').value);
|
||||
if (!bandId || bandId <= 0) {
|
||||
this.showError('Please enter a valid band ID');
|
||||
return;
|
||||
async addSingle() {
|
||||
const bandId = this.parseInteger(document.getElementById('addSingleBandId').value);
|
||||
if (!bandId || bandId <= 0) {
|
||||
this.showError('Please enter a valid band ID');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.makeRequest(`${this.API_BASE}/music-bands/${bandId}/add-single`, { method: 'POST' });
|
||||
document.getElementById('addSingleBandId').value = '';
|
||||
await this.loadBands();
|
||||
this.showSuccess(`Single added to band #${bandId}!`);
|
||||
} catch (error) {
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[App] Adding single to band ID: ${bandId}`);
|
||||
async removeParticipant() {
|
||||
const bandId = this.parseInteger(document.getElementById('removeParticipantBandId').value);
|
||||
if (!bandId || bandId <= 0) {
|
||||
this.showError('Please enter a valid band ID');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.makeRequest(`${this.API_BASE}/music-bands/${bandId}/add-single`, { method: 'POST' });
|
||||
document.getElementById('addSingleBandId').value = '';
|
||||
|
||||
await this.loadBands();
|
||||
|
||||
this.showSuccess(`Single added to band #${bandId}!`);
|
||||
} catch (error) {
|
||||
console.error('[App] Error adding single:', error);
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async removeParticipant() {
|
||||
const bandId = this.parseInteger(document.getElementById('removeParticipantBandId').value);
|
||||
if (!bandId || bandId <= 0) {
|
||||
this.showError('Please enter a valid band ID');
|
||||
return;
|
||||
try {
|
||||
await this.makeRequest(`${this.API_BASE}/music-bands/${bandId}/remove-participant`, { method: 'POST' });
|
||||
document.getElementById('removeParticipantBandId').value = '';
|
||||
await this.loadBands();
|
||||
this.showSuccess(`Participant removed from band #${bandId}!`);
|
||||
} catch (error) {
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[App] Removing participant from band ID: ${bandId}`);
|
||||
async importFile() {
|
||||
const fileInput = document.getElementById('importFile');
|
||||
const formatSelect = document.getElementById('importFormat');
|
||||
|
||||
try {
|
||||
await this.makeRequest(`${this.API_BASE}/music-bands/${bandId}/remove-participant`, { method: 'POST' });
|
||||
document.getElementById('removeParticipantBandId').value = '';
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
this.showError('Please select a file to import');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadBands();
|
||||
const file = fileInput.files[0];
|
||||
let format = formatSelect.value;
|
||||
|
||||
this.showSuccess(`Participant removed from band #${bandId}!`);
|
||||
} catch (error) {
|
||||
console.error('[App] Error removing participant:', error);
|
||||
this.showError(error.message);
|
||||
if (!format) {
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
if (['json', 'xml', 'yaml', 'yml'].includes(ext)) {
|
||||
format = ext === 'yml' ? 'yaml' : ext;
|
||||
} else {
|
||||
this.showError('Could not detect file format. Please select format manually.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContent = await this.readFileAsText(file);
|
||||
|
||||
const response = await fetch(`${this.API_BASE}/import?format=${format}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
},
|
||||
body: fileContent
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
fileInput.value = '';
|
||||
this.showSuccess(`Import completed successfully! ${data.objectsCount} bands imported.`);
|
||||
await this.loadBands();
|
||||
} else {
|
||||
const errorMsg = data.error || 'Import failed';
|
||||
const opId = data.operationId ? ` (Operation ID: ${data.operationId})` : '';
|
||||
this.showError(`Import failed: ${errorMsg}${opId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.showError(`Import error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readFileAsText(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target.result);
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
async loadImportHistory() {
|
||||
try {
|
||||
const history = await this.makeRequest(`${this.API_BASE}/import/history`);
|
||||
this.renderImportHistory(history);
|
||||
} catch (error) {
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
renderImportHistory(history) {
|
||||
const tbody = document.getElementById('historyTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (history.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td colspan="6" style="text-align: center; color: var(--muted);">No import history</td>';
|
||||
tbody.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
history.forEach((item) => {
|
||||
const row = document.createElement('tr');
|
||||
const statusClass = item.status === 'SUCCESS' ? 'text-success' : 'text-error';
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${item.id}</td>
|
||||
<td>${this.escapeHtml(item.username)}</td>
|
||||
<td class="${statusClass}">${item.status}</td>
|
||||
<td>${item.objectsCount ?? '—'}</td>
|
||||
<td>${new Date(item.createdAt).toLocaleString()}</td>
|
||||
<td>${item.errorMessage ? this.escapeHtml(item.errorMessage) : '—'}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
initializeWebSocket() {
|
||||
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
|
|
@ -907,64 +1098,50 @@ async removeParticipant() {
|
|||
const wsUrl = `${protocol}//${location.host}/is1/websocket/bands`;
|
||||
|
||||
try {
|
||||
console.log('[WS] Connecting to:', wsUrl);
|
||||
this.websocket = new WebSocket(wsUrl);
|
||||
|
||||
this.websocket.onopen = () => {
|
||||
console.log('[WS] Connection established');
|
||||
this.reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
this.websocket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[WS] Message received:', data);
|
||||
if (data.type === 'band_update') {
|
||||
this.handleBandUpdate(data.action);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[WS] Failed to parse message:', e);
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
this.websocket.onclose = (event) => {
|
||||
console.log('[WS] Connection closed:', event.code, event.reason);
|
||||
this.websocket = null;
|
||||
|
||||
this.reconnectAttempts = (this.reconnectAttempts || 0) + 1;
|
||||
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
|
||||
|
||||
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})...`);
|
||||
setTimeout(() => this.initializeWebSocket(), delay);
|
||||
};
|
||||
|
||||
this.websocket.onerror = (error) => {
|
||||
console.error('[WS] Error:', error);
|
||||
if (this.websocket) {
|
||||
this.websocket.close();
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[WS] Failed to connect:', e);
|
||||
setTimeout(() => this.initializeWebSocket(), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
handleBandUpdate(action) {
|
||||
console.log('[WS] Handling band update:', action);
|
||||
|
||||
if (['create', 'update', 'delete'].includes(action)) {
|
||||
this.loadBands();
|
||||
|
||||
if (action === 'create') {
|
||||
this.showInfo('New band added by another user');
|
||||
} else if (action === 'update') {
|
||||
this.showInfo('Band updated by another user');
|
||||
} else if (action === 'delete') {
|
||||
this.showInfo('Band deleted by another user');
|
||||
handleBandUpdate(action) {
|
||||
if (['create', 'update', 'delete'].includes(action)) {
|
||||
this.loadBands();
|
||||
if (action === 'create') {
|
||||
this.showInfo('New band added by another user');
|
||||
} else if (action === 'update') {
|
||||
this.showInfo('Band updated by another user');
|
||||
} else if (action === 'delete') {
|
||||
this.showInfo('Band deleted by another user');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.showToast(message, 'error');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user